Skip to content

feat(id): adaptive on-device bird ID router (BioCLIP-2, automatic)#259

Draft
jlian wants to merge 5 commits into
mainfrom
feat/on-device-bioclip
Draft

feat(id): adaptive on-device bird ID router (BioCLIP-2, automatic)#259
jlian wants to merge 5 commits into
mainfrom
feat/on-device-bioclip

Conversation

@jlian

@jlian jlian commented Jul 21, 2026

Copy link
Copy Markdown
Owner

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-adjust endpoint — 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 the MODELS R2 bucket with immutable caching + HTTP range support (files exceed CF's 25 MiB static-asset limit, so R2 is required).
  • MODELS R2 binding (prod + preview) + Env type.

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.tsidentifyBirdAdaptive() + 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.
  • AddPhotosFlow uses the router and warms the model on mount (network-gated). No feature flag, no settings toggle — the routing is automatic.
  • onnxruntime-web is dynamically imported so the main bundle is unaffected when the feature is off (verified: it code-splits out).

Verification

  • Full suite 709 tests green; new endpoint has 9 tests; tsc -b, eslint, and npm run build (Vite + Worker) all clean.
  • Inference path validated end-to-end via onnxruntime (same API as the browser): faithful embeddings, ~335 ms/img CPU.

⚠️ What's left / required to actually run this (NOT in this PR)

  1. Create + populate the R2 bucket. DONE. The wingdex-models bucket 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).
  2. Real WebGPU browser validation. Inference was validated in Node (onnxruntime, same API); actual in-browser WebGPU latency + the download/cache UX haven't been measured in a live browser yet. The Cloudflare preview for this PR is how we'll do that (on-device warms automatically on a suitable network; watch the Network tab for the model download, then it runs in-browser).
  3. Prefetch/download progress UX. Prefetch runs silently in the background; there's no progress UI / "preparing offline ID…" indicator yet. loadModel() reports download progress; surfacing it is follow-up.
  4. Offline range. On-device inference still calls /api/range-adjust for range refinement (needs network). True offline would ship a regional range table client-side — deferred.
  5. iOS / Core ML. Not in this PR. The 307 MB model is a non-issue bundled on the Neural Engine; that's a separate track.
  6. Model size. 307 MB int8 is the only variant that beats GPT (ViT-B/16 = 70/74, q4 = 78/87, both too weak — see 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

  • Model assets are not committed (307 MB). The spike (spike/bioclip/, incl. the standalone browser demo and all measurements) lives on the spike/bioclip-birdid branch.
  • Depends on nothing else, but pairs naturally with fix(range): scan full 3x3 neighbor ring for range priors #258 (the 8-neighbor range fix), which improves coastal accuracy for both models.

…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.
Copilot AI review requested due to automatic review settings July 21, 2026 05:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-adjust to 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.

Comment thread src/lib/bioclip-inference.ts
Comment thread src/lib/bioclip-inference.ts Outdated
Comment thread src/lib/bioclip-inference.ts
Comment thread src/components/pages/SettingsPage.tsx Outdated
Comment thread functions/models/[[path]].ts Outdated
Comment thread functions/models/[[path]].ts Outdated
Comment thread src/__tests__/range-adjust.test.ts
@github-actions
github-actions Bot temporarily deployed to preview (#259) July 21, 2026 05:14 Inactive
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.
Copilot AI review requested due to automatic review settings July 21, 2026 05:31
@jlian jlian changed the title feat(id): adaptive on-device bird ID router (BioCLIP-2, behind feature flag) feat(id): adaptive on-device bird ID router (BioCLIP-2, automatic) Jul 21, 2026
@github-actions
github-actions Bot temporarily deployed to preview (#259) July 21, 2026 05:34 Inactive

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 13 changed files in this pull request and generated 4 comments.

Comment thread src/lib/model-router.ts
Comment thread src/lib/model-router.ts
Comment thread src/components/flows/AddPhotosFlow.tsx
Comment thread functions/models/[[path]].ts
jlian added a commit that referenced this pull request Jul 21, 2026
#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.
Copilot AI review requested due to automatic review settings July 21, 2026 06:48
@github-actions
github-actions Bot temporarily deployed to preview (#259) July 21, 2026 06:50 Inactive
@github-actions

Copy link
Copy Markdown
Contributor

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() returns true when navigator.connection is unavailable (Safari/Firefox). That means maybePrefetchModel() 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.
Copilot AI review requested due to automatic review settings July 21, 2026 06:55
jlian added a commit that referenced this pull request Jul 21, 2026
)

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._
@github-actions
github-actions Bot temporarily deployed to preview (#259) July 21, 2026 06:58 Inactive

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 13 changed files in this pull request and generated 2 comments.

Comment on lines +102 to +106
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()),
])
Comment on lines +58 to +66
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')
Copilot AI review requested due to automatic review settings July 21, 2026 06:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()),
  ])

Comment on lines +22 to +23
* - otherwise TIER by range status (present > near-range > out-of-range),
* keeping model order within each tier.
Comment on lines +8 to +9
* Worker here with long-lived immutable caching and HTTP range support (so the
* browser can resume/stream the download).
Comment on lines +146 to +151
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 })
@jlian
jlian marked this pull request as draft July 21, 2026 07:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants