Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions coverage-atlas/.claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "coverage-atlas",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["dev"],
"port": 3000
}
]
}
13 changes: 13 additions & 0 deletions coverage-atlas/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# TinyFish — search and fetch are free, agent runs are metered.
# Key: https://agent.tinyfish.ai/api-keys
TINYFISH_API_KEY=

# OpenRouter — the collection agent's normalisation and extraction calls.
# Key: https://openrouter.ai/keys
OPENROUTER_API_KEY=

# Two-tier routing. Smart handles planning, source ranking, the shared tracker
# read and change narration — three or four calls per scan. Cheap handles the
# per-state extraction volume. Override either to trade cost against accuracy.
OPENROUTER_MODEL_SMART=anthropic/claude-sonnet-4.5
OPENROUTER_MODEL_CHEAP=google/gemini-2.5-flash
21 changes: 21 additions & 0 deletions coverage-atlas/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# v0 sandbox internal files
__v0_runtime_loader.js
__v0_devtools.tsx
__v0_jsx-dev-runtime.ts
.snowflake/
.v0-trash/
.vercel/

# Environment variables
.env*.local

# Common ignores
node_modules
.next/
.DS_Store
# local secrets
.env.local
.env*.local

# typescript incremental build cache
*.tsbuildinfo
185 changes: 185 additions & 0 deletions coverage-atlas/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# Coverage Atlas

**Live demo:** _TODO — add the deployed URL before opening the PR_

Coverage Atlas turns a plain-English condition — "GLP-1s for weight loss", "CGMs",
"ABA therapy for autism" — into a live scan of Medicaid coverage policy across all
50 states and DC. Because a coverage label alone is close to meaningless, it
extracts the administrative gates each state actually publishes and scores them:
on GLP-1s, **24 states carry the identical coverage status and sit 54 friction
points apart**. TinyFish does all the collection — `search` discovers sources and
dated announcements, `fetch` reads trackers and state policy documents, and the
stealth `agent` takes over when a state portal 403s a plain fetcher (several do).

## Demo

![Coverage Atlas](./docs/media/hero.png)

_TODO — replace with a recorded capture of a live scan: the map repainting state by
state as each jurisdiction lands, then the **Friction** toggle redrawing the country._

## The angle: coverage status is a lie

Every existing tracker answers "is it covered?" Two states can both say **covered**
and be forty points apart in what a patient faces — one requires prior
authorization, a documented failed trial, six months of a supervised program, a
specialist prescriber and quarterly reauthorization; the other puts it on the
pharmacy shelf.

So every jurisdiction gets an **Access Friction Index** (0–100), computed from the
gates its own documents state:

| Gate | Weight | | Gate | Weight |
|---|---:|---|---|---:|
| Prior authorization | 22 | | Renewal under 12 months | 8 |
| Step therapy | 18 | | Quantity limit | 7 |
| Documented prior failure | 14 | | Medical benefit only | 6 |
| Supervised program | 12 | | Restricted diagnosis | 6 |
| Clinical threshold (BMI, A1c…) | 10 | | Age restriction | 5 |
| Specialist prescriber only | 9 | | | |

Weights are additive then squashed, so the first two gates move the score a lot and
the seventh moves it little — which is how access actually fails. The map colours by
**status** or by **friction**, and on most conditions the two maps do not look alike.

Medicaid publishes no cross-state database and no change feed anywhere in the
country, so the delta has to be computed. Every change event says how we know it:

- **observed** — our own snapshot differ caught it between two scans
- **historical** — two dated versions of the state's own policy, read in one scan
and compared (a bulletin announcing a change states the rule it replaces)
- **reported** — a dated public announcement

## Where the TinyFish API is called

The escalation ladder, cheapest rung first. Full client: [`agent/lib/tinyfish.ts`](./agent/lib/tinyfish.ts).

```ts
// 1. search (free) — find the state's own policy document
const hits = await search(
`${stateName} Medicaid ${spec.treatmentClass} prior authorization criteria preferred drug list`,
)

// 2. fetch (free) — read the top candidates in ONE call, links included so
// outbound links become leads for the backfill pass
const docs = await fetchContents(rankPolicyUrls(hits, stateName).slice(0, 3))

// 3. agent (metered) — only when fetch came back empty, which for state
// Medicaid portals usually means a 403
const result = await runAgent({
url: target,
stealth: true, // state sites refuse plain fetchers
goal:
`Find what this page says about ${stateName} Medicaid fee-for-service coverage of ` +
`${spec.treatmentClass} for ${spec.name}. Return STRICT JSON only: ` +
`{"found":boolean,"status":"covered|conditional|limited|not_covered|unpublished",` +
`"frictionFlags":[...],"criteriaVerbatim":"exact wording or null",` +
`"effectiveDate":"YYYY-MM-DD or null","otherVersions":[...]}` +
` — otherVersions captures any DATED earlier or later version the page describes.`,
onProgress: (purpose) => emit(purpose),
})
```

`COMPLETED` only means the browser ran without crashing, so every agent result is
validated on content, never on status.

## Architecture

An orchestrator that owns the plan, the budget and the merge, and per-state
subagents that own nothing else. A subagent sees one state and ~2k tokens — never
its siblings' results, the tracker document, or the orchestrator's reasoning.

```mermaid
flowchart LR
U["Free text<br/><i>“GLP-1s for weight loss”</i>"] --> R["resolve<br/><i>smart LLM ×1</i>"]
R --> D["discover<br/><i>TinyFish search ×4</i>"]
D --> B["baseline<br/><i>TinyFish fetch + smart ×1</i><br/>one read, many states"]
B --> P{"plan"}
P -->|"settled"| M[("snapshot<br/>data/*.json")]
P -->|"residue"| F["fan-out · waves of 5"]

subgraph ladder["per-state subagent — stops at the first rung that answers"]
direction TB
L0["0 · carry-forward<br/><i>evidence hash unchanged — free</i>"]
L1["1 · search — free"]
L2["2 · fetch + window — free"]
L3["3 · stealth agent — metered"]
L0 --> L1 --> L2 --> L3
end

F --> ladder
ladder --> G["backfill<br/><i>banked leads, 10 states per fetch</i>"]
G --> C["changes<br/><i>news search + snapshot diff + dated versions</i>"]
C --> M
M --> UI["Next.js — map · matrix · timeline · compare"]
```

Bounded by two ceilings — **200 TinyFish calls** and **80 orchestrator steps** —
and it stops early, budget unspent, once every jurisdiction carries a timestamped,
cited answer. Anything unresolved when a ceiling binds is filled from model
knowledge and marked **unverified** everywhere it renders, never passed off as
sourced.

Why it stays cheap, measured on every run against a naive whole-document-per-state
loop:

| Mechanism | Effect |
|---|---|
| One tracker read | Settles most jurisdictions in a single normalisation call |
| Plan only fans out the residue | Confidently-answered states get no per-state call |
| Evidence hashing | Unchanged sources carry forward at zero cost on re-scans |
| Windowing | A 60–120k char drug list becomes ~5k of relevant passages |
| Batched backfill | Ten states' gap-filling leads in one fetch, not ten |
| Two-tier routing | 3–4 smart calls; all volume on the cheap model |

A representative live run: 51/51 jurisdictions sourced, 46 dated, 34 with policy
history, 0 inferred — stopped because every jurisdiction was answered, at 89/200
calls and 41/80 steps, **12.0× cheaper** than the naive loop.

Deeper docs live in [`docs/`](./docs): [architecture](./docs/ARCHITECTURE.md),
[the agent](./docs/AGENT.md), [data model](./docs/DATA-MODEL.md),
[HTTP API](./docs/API.md), [operations](./docs/OPERATIONS.md),
[decisions](./docs/DECISIONS.md).

## How to run

Node 20+ and pnpm.

```bash
pnpm install
cp .env.example .env.local
pnpm dev # http://localhost:3000
```

| Env var | Required | Notes |
|---|---|---|
| `TINYFISH_API_KEY` | to scan | Sent as `X-API-Key`. Search and fetch are free; agent runs are metered. |
| `OPENROUTER_API_KEY` | to scan | Normalisation and extraction calls. |
| `OPENROUTER_MODEL_SMART` | no | Default `anthropic/claude-sonnet-4.5`. Resolution, tracker read, change narration — 3–4 calls per scan. |
| `OPENROUTER_MODEL_CHEAP` | no | Default `google/gemini-2.5-flash`. Per-state extraction — all the volume. Must support strict JSON schema output. |

Reading the atlas needs **no keys at all** — a seeded 51-jurisdiction scan is
committed in `data/`, so the app opens with a full map offline. Keys are needed to
run a new scan and to re-verify a state.

Name a condition in the header and press **Run scan**. The same orchestrator runs
headless:

```bash
pnpm scan "GLP-1 drugs for weight loss"
pnpm scan "continuous glucose monitors" --depth deep --agent-budget 10
pnpm scan <saved-slug> --depth baseline --max-calls 120 --max-steps 60
pnpm agent:list # saved conditions, snapshots, change counts
pnpm agent:ledger # cost history
```

## Scope and honesty

Medicaid **fee-for-service** only — roughly three quarters of enrollees are in
managed care, which layers its own criteria on top. FFS is the published floor, not
the whole picture. Every record carries its source document, effective date,
extraction confidence, which ladder rung produced it, and a "last verified by our
scanner" timestamp; any record can be re-read live from the drawer. Verify against a
state's official publication before making a clinical or financial decision.

The fish mark is TinyFish's, used here because this is built on TinyFish.
104 changes: 104 additions & 0 deletions coverage-atlas/agent/lib/budget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Hard ceilings on a scan, and the rule for when it is done.
//
// A scanner that walks fifty-one independent sources, follows leads out of the
// pages it reads, and keeps digging until every gap is closed will happily run
// forever on a condition whose sources are thin. Two ceilings bound it:
//
// TinyFish calls — every search, fetch and browser run, counted together.
// This is the external spend and the wall-clock cost.
// Orchestrator steps — one discrete unit of the plan: a source read, a state
// processed, a backfill round. This bounds the *shape* of the work, so a
// cheap-but-endless loop cannot slip past the call cap.
//
// Whichever binds first stops the scan. The scan also stops early, and this is
// the good ending, when every jurisdiction has a timestamped policy with a
// citation — there is nothing left worth spending on.
//
// Running out is not a failure. Whatever is still missing when the budget closes
// gets filled from the model's own knowledge, marked `inferred`, and flagged for
// review — because a map with an honest low-confidence cell is more useful than
// a map with a hole in it, as long as it says which is which.

export type StopReason = "complete" | "call_cap" | "step_cap"

export type BudgetLimits = {
maxTinyfishCalls: number
maxSteps: number
maxAgentRuns: number
}

export const DEFAULT_LIMITS: BudgetLimits = {
maxTinyfishCalls: 200,
maxSteps: 80,
maxAgentRuns: 6,
}

export class Budget {
tinyfishCalls = 0
steps = 0
agentRuns = 0
private stopped: StopReason | null = null

constructor(readonly limits: BudgetLimits = DEFAULT_LIMITS) {}

/** True once either ceiling is reached. Checked before spending, never after. */
get exhausted(): boolean {
return this.tinyfishCalls >= this.limits.maxTinyfishCalls || this.steps >= this.limits.maxSteps
}

get stopReason(): StopReason {
if (this.stopped) return this.stopped
if (this.tinyfishCalls >= this.limits.maxTinyfishCalls) return "call_cap"
if (this.steps >= this.limits.maxSteps) return "step_cap"
return "complete"
}

/** Mark a clean finish: every jurisdiction answered before the ceilings bound. */
markComplete(): void {
if (!this.stopped) this.stopped = "complete"
}

/** Remaining TinyFish calls, so a phase can size its own fan-out to what is left. */
get callsLeft(): number {
return Math.max(0, this.limits.maxTinyfishCalls - this.tinyfishCalls)
}

get stepsLeft(): number {
return Math.max(0, this.limits.maxSteps - this.steps)
}

/**
* Reserve `n` TinyFish calls. Returns false when the reservation would breach
* the ceiling, and the caller skips the work rather than partially spending —
* a half-issued batch is harder to account for than one not issued.
*/
spendCalls(n = 1): boolean {
if (this.tinyfishCalls + n > this.limits.maxTinyfishCalls) return false
this.tinyfishCalls += n
return true
}

/** Browser runs are metered by TinyFish, so they carry their own smaller cap too. */
spendAgentRun(): boolean {
if (this.agentRuns >= this.limits.maxAgentRuns) return false
if (!this.spendCalls(1)) return false
this.agentRuns++
return true
}

spendStep(n = 1): boolean {
if (this.steps + n > this.limits.maxSteps) return false
this.steps += n
return true
}

snapshot() {
return {
tinyfishCalls: this.tinyfishCalls,
maxTinyfishCalls: this.limits.maxTinyfishCalls,
steps: this.steps,
maxSteps: this.limits.maxSteps,
stoppedBecause: this.stopReason,
}
}
}
Loading