# Project export: DoseDNA

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Your genes decide whether a drug works, fails, or harms you. DoseDNA reads your DNA file and explains what your results mean for your meds, straight from CPIC.
- Devpost: https://devpost.com/software/dosedna
- GitHub: https://github.com/alejandro-publius/dosedna
- Video: https://www.youtube.com/embed/KlmEp9JRON4?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 4 GitHub contributor(s) — alejandro-publius (64 commits), lindsayy-l (9 commits), rachelselbrede (3 commits), varsha106-pixel (1 commits)

## Devpost submission (written by the team)

### Inspiration

We kept noticing how often your genes decide whether a drug is safe — most importantly in oncology, which we all cared about. The DPYD gene, for example, makes the enzyme that breaks down the chemotherapy drug 5-fluorouracil. If you inherit a variant that weakens that enzyme, the drug never clears and builds up to toxic levels, so the same dose that cures one patient can land another in the hospital. And it isn't just cancer. For most everyday drugs, it comes down to the liver's cytochrome P450 enzymes, and the genes behind them vary widely from person to person. Slow metabolizers can't clear a drug, so it climbs to dangerous levels; ultrarapid metabolizers burn through it before it can work. One person's right dose is another's overdose, and another's waste of time. Hospitals already act on this with genetic panels and pharmacist-applied CPIC guidelines, but the millions of people sitting on a 23andMe, Ancestry, or other customer DNA file had no private way to ask what their results meant for a medication. That's the gap we built DoseDNA for.

### What it does

You drop in your raw 23andMe or Ancestry file and ask plain-language questions like "I was prescribed clopidogrel — what should I know?" DoseDNA reads your DNA, works out how you'd process that drug, and explains what it means in clear terms. The whole thing runs in your browser, so your genome never leaves your device. And when your data can't actually answer the question — which happens more often than you'd think — it tells you so instead of guessing.

### How we built it

DoseDNA is built on CPIC, the standard that hospitals and health providers reference. Its engine reads your raw DNA file and works through it step by step: which variants you carry, what they mean for how you process a drug, and what the guidance is. Because it follows fixed rules, the medical reasoning runs entirely on your own device (no API key, no model), and the answer is always computed, never guessed. A language layer (Claude Opus 4.8) sits on top only to put that verified answer into plain words, but it can't invent any of the medicine itself.

### Challenges we ran into

The first challenge was simply being a team that had never met! We only came together at the start of the event, so we had to quickly adapt to each other's work styles and abilities. We managed it by splitting the work by piece — one of us on the CPIC engine and data, another on the interface, another on the validation tests. The next was getting it clinically right, because in pharmacogenomics, a wrong answer is worse than no answer at all. Our mentor's bar was that validation had to trace back to real patient data — not our own memory, and not the model's training. So we anchored every test to a landmark trial — CYP2C19 poor metabolizers on clopidogrel (TAILOR-PCI, 2020), or SLCO1B1 and statin-induced myopathy (SEARCH, 2008) — and checked that our answer carried the exact concept each paper proved, while catching falsely reassuring phrases like "standard dose is fine."

### Accomplishments we're proud of

We're proud that the medicine is never made up — every answer comes from a CPIC clinical table, and the AI only phrases what the engine has already verified. As well, we're proud that the genome never leaves the device, so privacy is built into the architecture rather than promised in a policy. And honestly, we're proud that we shipped something this careful as a team that had only just met!

### What we learned

We were honestly surprised by how much you can build in a single weekend. We also finally understood why a single variant can flip a drug's effect: with an ordinary, already-active drug, a slow metabolizer overdoses because they can't clear it; but with a prodrug, the same person gets no effect because the drug never activates. Whether the body needs to clear the drug or switch it on is the hinge on which everything turns.

### What's next

Right now, we cover a focused set of genes and drugs to prove the idea works; the natural next step is broadening that to more gene–drug pairs and supporting more file formats. We'd also want a real pharmacist and clinician review of the guidance before anyone relies on it. Longer term, we see the same rigorous engine living in two places: helping individuals understand their own results, and giving clinicians a faster, trustworthy tool — moving "genotype before treatment" from something only hospitals do toward something everyone can reach.

## README (from the GitHub repository)

# DoseDNA

> **Ask your genome a straight question.** A chat agent that reads your
> 23andMe / AncestryDNA file in your browser, calls your phenotypes with a
> deterministic engine, fetches CPIC's verbatim clinical recommendation
> live, and answers in plain language — with provider-side anonymity via
> cover traffic.

The conversation is on top. Underneath, it's a deterministic engine that
calls your phenotypes from PharmVar variant tables and CPIC diplotype
rules, fetches the actual CPIC drug recommendation from
`api.cpicpgx.org`, and grounds every clinical claim in a click-through
citation. The LLM only paraphrases verified output.

## Status

Built for [AI Hackathon 2026 at Berkeley](https://hackberkeley.org),
**Best Beginner Hack** track. Science-fair judging Sunday June 21,
1–3pm.

Live preview (landing page only, chat requires the local proxy):
[alejandro-publius.github.io/dosedna](https://alejandro-publius.github.io/dosedna/)

For the competitive landscape and where DoseDNA fits in the existing PGx
market, see **[MARKET_LANDSCAPE.md](MARKET_LANDSCAPE.md)** — cited
comparison vs. 23andMe Health (and its 2023 breach + 2025 bankruptcy),
Genomind, OneOme, PharmCAT, and vanilla LLMs.

For the judging-day pitch script, timing, and Q&A drills, see
**[DEMO.md](DEMO.md)**.

---

## What's built

### Chat agent UI (`4-agent-chat.html` / `index.html`)
Lindsay's landing page is the demo entry point. Load a DNA file, ask a
question in natural English. Replies render with:
- **CPIC evidence-strength chips** (green Strong / amber Moderate / gray
  Optional) that link to the published guideline on `cpicpgx.org`.
- **Tool-call cards** under each reply showing which tools the agent
  invoked and with what arguments.
- **Cover-traffic chip** indicating how many decoy queries were fired
  in parallel to the same provider.

### Deterministic PGx engine (`src/pgx.js`)
- 6 genes called locally in the browser: CYP2C19, CYP2C9, VKORC1,
  SLCO1B1, TPMT, CYP2D6.
- CYP2D6 is always "Coverage limited" — consumer arrays cannot reliably
  call its structural variants, and the engine refuses to guess.
- Phenotype-if-invariant rule (BUILD_SPEC §7): when phase or coverage is
  ambiguous, enumerate every possible assignment; only report a phenotype
  if every branch agrees. Otherwise "Not determined."
- **79/79 unit tests** in `tests/pgx.test.mjs`, against PharmVar's
  variant definitions and CPIC's published diplotype tables.

### In-browser parser (`src/parser.worker.js`)
- 23andMe **and** AncestryDNA TSV formats — auto-detected from header +
  column count.
- Runs in a Web Worker; UI never freezes.
- File bytes stay in worker scope; only `{rsid: "AG", ...}` for the 10
  target SNPs is handed back to the page.
- **33 parser tests** in `tests/parser.test.mjs`.

### Hardened proxy (`server/proxy.py`)
- **One** endpoint, `POST /api/explain`, discriminated by a `kind` field
  (`explain` | `questions` | `interactions` | `chat`).
- Holds `ANTHROPIC_API_KEY`. Browser never sees it.
- Allowlist built from `genes.json` + `drugs.json` at startup — every
  `(gene, phenotype, drug)` tuple validated before any string reaches
  Claude.
- Defense-in-depth: rejects payloads containing rsID-shaped strings or
  long ACGT runs.
- Per-IP rate limit, CORS locked to localhost.
- No request body logging.
- Model: `claude-opus-4-8`.

### Four agent tools (Anthropic tool-use)
1. **`get_gene_status(gene)`** — reads the user's phenotype for one gene
   from the deterministic engine's output.
2. **`lookup_cpic_recommendation(gene, drug, phenotype)`** — fetches CPIC's
   verbatim implications + recommendation + evidence classification from
   `api.cpicpgx.org/v1/recommendation`.
3. **`check_drug_interactions()`** — runs the deterministic engine
   over the user's medications: drug-drug pairs + phenoconversion shifts.
4. **`suggest_clinician_questions(focus_topic)`** — generates 4–6
   concrete questions for a clinician visit.

### CPIC integration
- **Live**: `api.cpicpgx.org/v1/recommendation` queried in real time.
- **Disk-cached**: `src/data/cpic_recommendations.json` — pre-pulled CPIC
  recommendations for all 17 bundled drugs (built by
  `scripts/cache_cpic.py`). The proxy pre-seeds its in-memory caches at
  startup, so the demo works even if `api.cpicpgx.org` is down.

### Deterministic interactions (`src/data/interactions.json`)
- 8 phenoconversion entries (inhibitor / inducer pairs) with FDA / CPIC /
  DPWG citations.
- 6 drug-drug interactions (clopidogrel + omeprazole, warfarin +
  amiodarone, simvastatin + clarithromycin, etc.) with FDA / CPIC
  citations.
- No live LLM reasoning — every clinical claim is grounded in a bundled,
  citable source.

### Cover-traffic / decoy queries (`server/proxy.py`)
- Every real chat turn spawns **5 decoy Anthropic calls** on daemon
  threads — same model, same system prompt, random `(gene, phenotype,
  drug)` from the allowlist. Their responses are read and discarded.
- The provider's API log therefore contains 6 indistinguishable requests
  per real user turn; they cannot identify which call was the user's
  real question.
- Zero added user-perceived latency (decoys fire in the background after
  the real reply is computed).

### Bundled data
- `src/data/genes.json` — variants, function tables, diplotype rules.
- `src/data/drugs.json` — 17 drugs × phenotypes with CPIC-derived guidance.
- `src/data/interactions.json` — phenoconversion + drug-drug pairs.
- `src/data/cpic_recommendations.json` — disk cache of CPIC API responses.
- `sample/sample_23andme.txt` — bundled demo file.
- `sample/patients/` — 5 synthetic patient files curated by our
  bio collaborator, with documented ground-truth seeded variants in
  `sample/patients/expected/patient_0X.json`.

---

## Privacy posture

We do NOT claim "perfect privacy." We claim **architectural minimization
plus provider-side anonymity**:

1. **Raw DNA never leaves the browser.** The parser worker and the PGx
   engine run in-page; file bytes stay in worker scope. The Privacy
   Console makes this falsifiable in real time — open it, watch the
   "0 raw DNA bytes uploaded" counter.

2. **Only de-identified labels reach the LLM.** Every payload to
   Anthropic contains `{gene, phenotype, drug name}`. No rsIDs, no
   genotypes, no age, sex, name, location, or file content. The proxy's
   allowlist + DNA-shape regex enforce this at the boundary.

3. **Provider-side anonymity via cover traffic.** Even those minimal
   labels are mixed with 5 decoy queries per real chat turn. Anthropic's
   API log shows 6 indistinguishable requests; they cannot link any of
   them to a user.

4. **We log nothing on the proxy.** Request bodies are never written to
   disk; no analytics, no telemetry.

What we do NOT do today: ship a local LLM. That's a real future
direction (WebLLM in the browser, or per-user BYOK keys) — see
[ROADMAP.md](#roadmap) below. Honest about what's prototype vs.
production.

---

## Validation

Honest about what's external and what's internal — full audit in
`tests/`.

### External validation
- **79 PGx engine unit tests** (`tests/pgx.test.mjs`): synthetic
  genotypes → expected phenotypes against PharmVar + CPIC reference
  tables. This part is externally validated against the field's
  authoritative sources.
- **33 parser tests** (`tests/parser.test.mjs`): 23andMe + AncestryDNA
  format coverage.
- **Literature-grounded test, 7/7 passing** (`tests/literature-grounded.test.mjs`):
  each case anchored to a specific peer-reviewed paper or landmark RCT.
  Sources: TAILOR-PCI (JAMA 2020), SEARCH (NEJM 2008), EU-PACT (NEJM
  2013), Colombel et al. (Gastroenterology 2000), Smith et al.
  (Genetics in Medicine 2019), Hicks et al. (Clin Pharmacol Ther 2017),
  Bishop et al. (Frontiers Pharmacology 2019).
- **PGxQA benchmark** (`tests/pgxqa.test.mjs`, Keat et al., PSB 2025):
  expert-review tier — 5/6 partial matches on in-scope cases, 4/4
  clean refusal on out-of-scope.
- **Patient benchmark — 19/19 passing** (`tests

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 16 recognized source files, 249 KB.
- Anthropic (technology) — detected in the code
- FastAPI (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- Python (language) — detected in the code
- Node.js (technology) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (61 of 61)

```
.gitignore
.nojekyll
4-agent-chat.html
BUILD_SPEC
DEMO.md
index.html
Makefile
MARKET_LANDSCAPE.md
README.md
sample/expected_phenotypes.json
sample/patients/annotated/patient_01_annotated.txt
sample/patients/annotated/patient_02_annotated.txt
sample/patients/annotated/patient_03_annotated.txt
sample/patients/annotated/patient_04_annotated.txt
sample/patients/annotated/patient_05_annotated.txt
sample/patients/expected/patient_01.json
sample/patients/expected/patient_02.json
sample/patients/expected/patient_03.json
sample/patients/expected/patient_04.json
sample/patients/expected/patient_05.json
sample/patients/patient_01_23andme.txt
sample/patients/patient_02_23andme.txt
sample/patients/patient_03_23andme.txt
sample/patients/patient_04_23andme.txt
sample/patients/patient_05_23andme.txt
sample/sample_23andme.txt
scripts/cache_cpic.py
scripts/precompute_explanations.py
scripts/smoketest.sh
server/.env.example
server/proxy.py
server/requirements.txt
src/data/_loader.js
src/data/_provenance.json
src/data/allele_definition.json
src/data/allele_functionality.json
src/data/cpic_recommendations.json
src/data/diplotype_phenotype.json
src/data/drugs.json
src/data/gene_drug_pairs.json
src/data/genes.json
src/data/guidelines.json
src/data/interactions.json
src/data/recommendations.json
src/explain.js
src/main.js
src/parser.worker.js
src/pgx.js
tests/agent.test.mjs
tests/getrm.test.mjs
tests/literature-grounded.test.mjs
tests/parser.test.mjs
tests/patient-benchmark.test.mjs
tests/pgx-matrix/expected.tsv
tests/pgx-matrix/questions.tsv
tests/pgx.test.mjs
tests/pgxqa-fixtures/expert_review_questions.tsv
tests/pgxqa-fixtures/README.md
tests/pgxqa-fixtures/UPSTREAM_LICENSE
tests/pgxqa.test.mjs
tests/variation-2.html
```

### Dependencies

- server/requirements.txt: anthropic@>=0.40, fastapi@>=0.110, pydantic@>=2.6, python-dotenv@>=1.0, uvicorn[standard]@>=0.27

### Recent commits (newest first)

- Tighten data-path section heading — drop overclaim that 'DNA stays here'
- Swap hero + tools-section copy to variation-2's voice (Lindsay's design)
- Merge branch 'main' of https://github.com/alejandro-publius/dosedna
- Port Lindsay's variation-2 best parts — clopidogrel/escitalopram contrast + privacy data-flow table
- Add files via upload
- Add annotated duplicates of patient files (per-drug expected outcomes prepended)
- Add gene-drug expected-outcome matrix (18 pairs)
- Remove Privacy Console — redundant with decoy chip + strip stats
- Defer to Rachel's benchmark (19/19), drop my superseded mock-patients test, move privacy badge to bottom-left
- Merge branch 'main' of https://github.com/alejandro-publius/dosedna
- Add one-line pharmacogenomics definition under the hero pill
- Add repeatable patient benchmark test; fix patient 01/04 genotypes
- Tool-using decoys: same request shape as real chat
- Tighten over-claims in privacy copy and demo Q&A drills
- Add mock-patient end-to-end test — 10/10 against Rachel's curated ground truth
- Final orphan sweep: remove FLETCHER_REVIEW.md + empty mock-patients dir, link DEMO.md from README
- Remove orphan tests/variation-2.html
- Add cited market landscape — competitor map + per-flaw mapping
- Drop 'local everything' overclaim — lead with browser-local parsing + decoy cover-traffic
- Fix tool names + privacy claim to match what the backend actually does

## Key source files (fetched from GitHub, selected and truncated for size)

### MARKET_LANDSCAPE.md

```markdown
# Market landscape — where DoseDNA fits

This is the *"here is the current system… and here are the flaws / here
is our system that does not have those issues"* slide the mentor asked
for. Every number is cited; no estimates.

## The map

| Product | Who it's for | Cost | Privacy posture | Output format | Documented failure mode |
|---|---|---|---|---|---|
| **23andMe Health Service** (TTAM-owned, 2025–) | Consumer | $99–199 + Health subscription | Whole genome uploaded to vendor servers, 10-year+ retention | Static PDF reports | **2023 breach exposed PGx data + ancestry for ~7M people; £2.31M ICO fine; $30M class-action settlement (final Jan 2026); company went bankrupt and the 15M-customer genetic database was sold to TTAM in July 2025** |
| **Genomind PGx** | Mental-health patient (clinician-ordered) | $300–500 out-of-pocket | Saliva → CLIA lab, vendor retains data | Clinician report | Requires physician order; uneven insurance coverage; weeks to result; no patient-facing Q&A |
| **OneOme RightMed** | Multi-specialty patient (clinician-ordered) | $300–600 | Saliva → CLIA lab | Clinician report | **Company went out of business in 2025; assets acquired by Tempus AI** — long-tail support unclear |
| **PharmCAT** (PharmGKB) | Clinician / bioinformatician | Free (MPL 2.0, open source) | Local (if user can run the Java CLI on a VCF) | JSON / HTML report | **CLI-only, no patient UI, requires VCF (not consumer 23andMe/Ancestry format), Java dependency, limited real-world validation** |
| **Vanilla LLMs** (ChatGPT, Claude.ai, etc.) | Consumer | $0–$20/mo | Genome data → provider logs | Free-form chat | **Cannot read a DNA file. No source of truth → hallucinates clinical claims. No refusal behavior. No phenotype calling.** |
| **DoseDNA** *(this project)* | Consumer | Free (open source) | Parse in browser + decoy cover traffic + de-identified labels only | Chat with click-through CPIC citations + tool trace | 17 CPIC drugs (not full catalog), research prototype, no clinician sign-off |

## The hole DoseDNA fills

Plot each product on (free vs paid) × (patient vs clinician) × (uploads
genome vs local):

- **Patient-facing + free + does NOT upload your genome:** that quadrant is empty in the existing market. Every patient-facing PGx tool either charges $99–600 OR uploads your genome OR both. The one free local tool (PharmCAT) is a Java CLI for clinicians.

DoseDNA is the first patient-facing free option that doesn't require uploading.

## Per-flaw mapping

Mentor's framework — current system flaws → DoseDNA's answer:

| Flaw in existing system | DoseDNA's mitigation | Mechanism |
|---|---|---|
| 23andMe's 7M-account 2023 breach exposed PGx data | Raw DNA never leaves the browser | Parser worker runs in-page; only de-identified labels (gene, phenotype, drug name) are ever sent to the proxy; the proxy's DNA-shape regex rejects any payload that looks like raw genotypes |
| 23andMe genome database was sold to a new owner (TTAM) under bankruptcy | Architectura
[truncated — 3262 more characters]
```

### DEMO.md

```markdown
# DoseDNA — demo script

**Event:** AI Hackathon 2026 at Berkeley · **Track:** Best Beginner Hack
**Judging:** Sunday June 21, 1–3pm · science fair format
**Slot:** 5 minutes total per judge — **3 min pitch + 2 min Q&A.** Rehearse twice.

---

## One-line pitch (memorize)

> *DoseDNA is a chat agent that reads your DNA file in your browser and answers, in plain language, how your DNA affects medications — grounded in CPIC's live clinical guidelines, anchored to peer-reviewed RCTs, and built so that even what does leave your laptop is statistically anonymous to the LLM provider.*

---

## The 3 minutes

**0:00 – 0:20 — Hook**

> *"Your DNA decides whether a drug works, fails, or hurts you. Today, finding out means a $1,000 clinical test or uploading your genome to a website you can't get back. We built a third option — a chat agent that does the interpretation in your browser, with the privacy guarantees made checkable."*

**0:20 – 0:50 — Load DNA + show local parsing**

*(Click **Load sample DNA**.)*

> *"This 23andMe file is being read in this tab. The deterministic engine — 79 unit tests against PharmVar's variant catalog and CPIC's diplotype tables — just called six phenotypes."*

*(Point at the phenotype chips that appear.)*

> *"Zero raw DNA bytes left this laptop. The strip below the headline tracks it; the proxy's DNA-shape regex actively rejects any payload that looks like raw genotypes."*

**0:50 – 1:40 — Ask a real question + show CPIC grounding**

*(Type: "Should I be worried about clopidogrel and omeprazole?")*

*(Reply appears.)*

> *"Two things happened. First, look at the chips below the reply — green 'CPIC Strong' for clopidogrel, amber 'CPIC Moderate' for omeprazole. These pull CPIC's actual evidence classification from the live CPIC API — click any of them and you land on the published guideline with the peer-reviewed studies CPIC used to assign the rating."*

*(Click a chip — `cpicpgx.org` opens in a new tab.)*

> *"Second — the agent itself caught the phenoconversion: omeprazole inhibits the enzyme that activates clopidogrel. That's not the LLM hallucinating; it's a deterministic lookup against our bundled FDA-cited interactions table."*

**1:40 – 2:10 — The privacy moment (this is the closer)**

*(Point at the blue shield chip below the reply.)*

> *"Here's where we go further than 'parsed locally.' The phenotype-and-drug summary did go to Claude, because that's what the chat needs. But every time you hit send, your real query is mixed with five decoy queries drawn at random from our allowlist — same model, same system prompt, fired in parallel. Anthropic logs six requests per turn. They can't tell which one was you. Your real question is statistically anonymous from the provider's view."*

**2:10 – 2:40 — How we know it works**

> *"We didn't validate by saying 'CPIC says so' — that's circular. We built a literature-grounded test suite where each case is anchored to a specific peer-reviewed RCT: TAILOR-PCI for clopidogrel, S
[truncated — 7066 more characters]
```

### server/requirements.txt

```
fastapi>=0.110
uvicorn[standard]>=0.27
anthropic>=0.40
python-dotenv>=1.0
pydantic>=2.6

```

### src/main.js

```javascript
// Glue layer: wires UI -> parser worker -> pgx logic -> proxy.
//
// Contract this file expects from index.html (Varsha owns the markup):
//   #dna-file-input       <input type="file">
//   #file-status          element where parse status text goes
//   #results              container where per-gene result cards render
//   #meds-input           <input type="text"> for the medications list
//   #meds-check-btn       button to run the interaction check
//   #meds-results         container where flagged interactions render
//   #doctor-questions-btn button to generate clinician questions
//   #doctor-questions     <ul> where bulleted questions render
//   #demo-load-btn        (optional) loads the bundled sample file
//
// Contract this file expects from src/pgx.js (Lindsay):
//   import { genotypesToResults } from "./pgx.js";
//   genotypesToResults(genotypeMap) -> Array<{
//     gene, phenotype,
//     drugs: [{ drug, flag, recommendation }]
//   }>
//
// Contract this file expects from src/parser.worker.js (Lindsay):
//   postMessage({ type: "parse", fileText: string })
//   -> postMessage({ type: "result", genotypes: { rsId: "AG", ... } })
//   -> postMessage({ type: "error", message: string })

import {
  fetchExplanation,
  fetchDoctorQuestions,
  fetchMedInteractions,
} from "./explain.js";
const fileInput = document.getElementById("dna-file-input");
const statusEl = document.getElementById("file-status");
const resultsEl = document.getElementById("results");
const medsInput = document.getElementById("meds-input");
const medsCheckBtn = document.getElementById("meds-check-btn");
const medsResultsEl = document.getElementById("meds-results");
const doctorBtn = document.getElementById("doctor-questions-btn");
const doctorListEl = document.getElementById("doctor-questions");
const demoBtn = document.getElementById("demo-load-btn");

let worker = null;
let currentResults = [];

function setStatus(text) {
  if (statusEl) statusEl.textContent = text;
}

function startWorker() {
  if (worker) worker.terminate();
  worker = new Worker(new URL("./parser.worker.js", import.meta.url), {
    type: "module",
  });
  worker.onmessage = (event) => {
    const { type } = event.data;
    if (type === "result") handleParsedGenotypes(event.data.genotypes);
    else if (type === "error") setStatus(`Parse error: ${event.data.message}`);
  };
  worker.onerror = (event) => {
    setStatus(
      `Worker failed to load: ${event.message || event.filename || "unknown"}`,
    );
  };
}

async function handleParsedGenotypes(genotypes) {
  // Privacy boundary: only the COUNT of parsed SNPs is surfaced in the UI or
  // logged anywhere. Genotype values stay inside this function's scope and
  // flow into pgx.js (in-page) -> currentResults (gene/phenotype only).
  // They must never appear in setStatus, console.log, fetch bodies, etc.
  setStatus(`Parsed ${Object.keys(genotypes).length} target SNPs locally.`);
  const { genotypesToResults } = await import("./pgx.js");
  currentResults = await genotypesToResults(genotypes);
  renderResults(currentResults);
  if (doctorBtn) doctorBtn.disabled = false;
  if (medsCheckBtn) medsCheckBtn.disabled = false;
}

function flagColor(flag) {
  return { green: "#52d273", amber: "#ffb454", red: "#ff5f6d", gray: "#8d97a7" }[
    flag
  ] || "#8d97a7";
}

const COVERAGE_LABELS = {
  confident: "Tested",
  partial: "Partially tested",
  "not-callable": "Not callable from this file",
};

function renderResults(results) {
  resultsEl.innerHTML = "";
  for (const result of results) {
    const card = document.createElement("article");
    card.className = "gene-card";
    const coverageChip = result.coverage_state
      ? `<span class="coverage-state coverage-${result.coverage_state}">${COVERAGE_LABELS[result.coverage_state] ?? ""}</span>`
      : "";
    const hasDrugs = result.drugs.length > 0;
    card.innerHTML = `
      <header>
        <h3>${result.gene}</h3>
        <span class="phenotype">${result.phenotype}</span>
        ${coverageChip}
      </header>
      <ul class="drugs"></ul>
      ${hasDrugs ? '<button class="explain-btn" type="button">Explain with AI</button>' : ""}
      <p class="explanation" hidden></p>
    `;
    const drugList = card.querySelector(".drugs");
    for (const d of result.drugs) {
      const li = document.createElement("li");
      li.className = `drug flag-${d.flag}`;
      li.style.borderLeft = `4px solid ${flagColor(d.flag)}`;
      li.textContent = `${d.drug}: ${d.recommendation}`;
      drugList.appendChild(li);
    }
    if (hasDrugs) {
      const btn = card.querySelector(".explain-btn");
      const expEl = card.querySelector(".explanation");
      btn.addEventListener("click", () => loadExplanation(result, btn, expEl));
    }
    resultsEl.appendChild(card);
  }
}

async function loadExplanation(result, btn, expEl) {
  const first = result.drugs[0];
  if (!first) return;
  btn.disabled = true;
  btn.textContent = "Loading...";
  try {
    const data = await fetchExplanation({
      gene: result.gene,
      phenotype: result.phenotype,
      drug: first.drug,
      coverageState: result.coverage_state,
    });
    const prefix =
      data.source === "fallback" ? "(AI offline — showing static guidance.) " : "";
    expEl.textContent = `${prefix}${data.explanation}`;
  } catch {
    expEl.textContent = first.recommendation;
  }
  expEl.hidden = false;
  btn.hidden = true;
}

function parseMeds(raw) {
  return raw
    .split(/[,\n]/)
    .map((s) => s.trim().toLowerCase())
    .filter(Boolean);
}

function phenotypePayload() {
  return currentResults.map((r) => ({ gene: r.gene, phenotype: r.phenotype }));
}

async function onCheckMeds() {
  if (!medsInput || !medsResultsEl) return;
  const meds = parseMeds(medsInput.value);
  if (meds.length === 0) {
    medsResultsEl.textContent = "Enter at least one medication.";
    return;
  }
  medsCheckBtn.disabled = true;
  medsCheckBtn.textContent = "Reasoning...";
  medsResultsEl.innerHTML = "";
  tr
[truncated — 4003 more characters]
```

### scripts/smoketest.sh

```shell
#!/usr/bin/env bash
# Hit every proxy endpoint with a realistic payload. Run while `make proxy` is up.
# Usage: bash scripts/smoketest.sh   (or:  make smoketest)
set -euo pipefail

PROXY="${PROXY:-http://localhost:8001}"
PASS=0
FAIL=0

green()  { printf "\033[32m%s\033[0m\n" "$*"; }
red()    { printf "\033[31m%s\033[0m\n" "$*"; }
dim()    { printf "\033[2m%s\033[0m\n" "$*"; }

check() {
  local name=$1 method=$2 path=$3 body=${4:-}
  printf "%-22s " "$name"
  local code
  if [[ -z "$body" ]]; then
    code=$(curl -sS -o /tmp/dosedna_resp.json -w '%{http_code}' "$PROXY$path")
  else
    code=$(curl -sS -o /tmp/dosedna_resp.json -w '%{http_code}' \
           -X "$method" "$PROXY$path" \
           -H 'Content-Type: application/json' -d "$body")
  fi
  if [[ "$code" == 2* ]]; then
    green "OK ($code)"
    PASS=$((PASS+1))
    dim "  $(head -c 180 /tmp/dosedna_resp.json)..."
  else
    red "FAIL ($code)"
    FAIL=$((FAIL+1))
    cat /tmp/dosedna_resp.json
    echo
  fi
}

echo "== DoseDNA proxy smoke test =="

check "health"       GET  "/"
check "explain"      POST "/api/explain" \
  '{"gene":"CYP2C19","phenotype":"Poor metabolizer","drug":"clopidogrel"}'
check "questions"    POST "/api/questions" \
  '{"phenotypes":[{"gene":"CYP2C19","phenotype":"Poor metabolizer"},{"gene":"SLCO1B1","phenotype":"Decreased function"}],"medications":["clopidogrel","simvastatin"]}'
check "check-meds"   POST "/api/check-meds" \
  '{"phenotypes":[{"gene":"CYP2C19","phenotype":"Normal metabolizer"}],"medications":["clopidogrel","omeprazole"]}'

echo
if [[ "$FAIL" -eq 0 ]]; then
  green "All $PASS checks passed."
else
  red   "$FAIL failed, $PASS passed."
  exit 1
fi

```

### src/explain.js

```javascript
// All proxy clients. Each function sends ONLY {gene, phenotype, drug, meds}
// shaped payloads. No DNA, no rsIDs, no identifiers ever cross the network.
// Set window.DOSEDNA_PROXY before main.js loads to point at a non-default host.

const PROXY = globalThis.DOSEDNA_PROXY ?? "http://localhost:8001";
const TIMEOUT_MS = 30000;

async function postJson(path, body) {
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
  try {
    let res;
    try {
      res = await fetch(`${PROXY}${path}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
        signal: ctrl.signal,
      });
    } catch (err) {
      // Distinguish timeout vs proxy-down vs other network errors. The demo
      // machine will hit the second case if uvicorn isn't running.
      if (err && err.name === "AbortError") {
        throw new Error(`Proxy timed out after ${TIMEOUT_MS}ms`);
      }
      if (err instanceof TypeError) {
        throw new Error("Proxy is offline — start the server with `make proxy`");
      }
      throw err;
    }
    if (!res.ok) throw new Error(`${path} returned ${res.status}`);
    return res.json();
  } finally {
    clearTimeout(timer);
  }
}

/**
 * Request a plain-language explanation from the proxy.
 * @returns {Promise<{explanation: string, source: "bundle" | "claude" | "fallback"}>}
 *   `source` distinguishes a precomputed cache hit ("bundle") from a live
 *   Claude call ("claude") from a Claude-unreachable static fallback
 *   ("fallback"). UI labels the third one.
 */
// BUILD_SPEC §12a: one proxy endpoint. The previous /api/questions and
// /api/check-meds paths were collapsed into /api/explain; the server now
// discriminates on `kind` ("explain" | "questions" | "interactions").
export async function fetchExplanation({ gene, phenotype, drug, coverageState }) {
  const body = { kind: "explain", gene, phenotype, drug };
  if (coverageState) body.coverage_state = coverageState;
  const data = await postJson("/api/explain", body);
  return { explanation: data.explanation, source: data.source };
}

export async function fetchDoctorQuestions({ phenotypes, medications }) {
  const data = await postJson("/api/explain", {
    kind: "questions",
    phenotypes,
    medications: medications ?? [],
  });
  return data.questions;
}

export async function fetchMedInteractions({ phenotypes, medications }) {
  return postJson("/api/explain", {
    kind: "interactions",
    phenotypes,
    medications,
  });
}

/**
 * Send a chat turn to the proxy. The proxy runs an Anthropic tool-use loop
 * against the deterministic spine (read gene status, map drug guidance,
 * check interactions, suggest clinician questions) and returns:
 *   - reply: the final assistant text
 *   - tool_trace: which tools fired and with what inputs (for "underneath,
 *     it's a calculator" demo affordances)
 *   - source: "claude" | "fallback"
 *
 * The proxy never sees DNA. We pass phenotypes (verified labels like
 * "CYP2C19 Intermediate metabolizer") and medication names only.
 */
export async function fetchChat({
  message,
  conversation,
  phenotypes,
  medications,
}) {
  return postJson("/api/explain", {
    kind: "chat",
    message,
    conversation: conversation ?? [],
    phenotypes: phenotypes ?? [],
    medications: medications ?? [],
  });
}

```

### scripts/cache_cpic.py

```python
"""
Build a disk cache of CPIC recommendations for every drug in src/data/drugs.json.

Run with: python3 scripts/cache_cpic.py
Output:   src/data/cpic_recommendations.json

The proxy loads this file at startup and pre-seeds its in-memory CPIC caches
(_CPIC_DRUGID_CACHE, _CPIC_RECS_CACHE) so the live CPIC API is only consulted
for drugs that weren't in the bundle. Demo-bulletproof: if api.cpicpgx.org is
down at 1:30pm Sunday, every drug the agent looks up still resolves instantly
from disk.

Re-run this script whenever the bundled drug list changes or when CPIC updates
their guidelines (rare — they version explicitly). The cache file embeds a
`generated_at` timestamp and the upstream API base URL.
"""

import datetime
import json
import sys
import time
from pathlib import Path

# httpx is already in server/.venv (transitive dep of the anthropic SDK).
# Using it instead of stdlib urllib so we pick up certifi's trust store —
# macOS system Python 3.9 ships without one, which breaks raw urllib HTTPS.
import httpx

REPO_ROOT = Path(__file__).resolve().parent.parent
DRUGS_PATH = REPO_ROOT / "src" / "data" / "drugs.json"
OUTPUT_PATH = REPO_ROOT / "src" / "data" / "cpic_recommendations.json"

CPIC_API_BASE = "https://api.cpicpgx.org/v1"
REQUEST_TIMEOUT_S = 10.0
SLEEP_BETWEEN_REQUESTS_S = 0.1  # be polite to the API

_http = httpx.Client(timeout=REQUEST_TIMEOUT_S, headers={"Accept": "application/json"})


def _get_json(url: str):
    resp = _http.get(url)
    resp.raise_for_status()
    return resp.json()


def _drug_names_from_bundle() -> list:
    if not DRUGS_PATH.exists():
        sys.exit(f"Missing {DRUGS_PATH}. Cannot enumerate drugs.")
    with DRUGS_PATH.open() as fh:
        data = json.load(fh)
    seen = set()
    out = []
    for gene_drugs in data.get("drugs", {}).values():
        for drug_name in gene_drugs.keys():
            key = drug_name.lower().strip()
            if key in seen:
                continue
            seen.add(key)
            out.append(drug_name)
    out.sort(key=str.lower)
    return out


def _resolve_drugid(drug_name: str):
    url = f"{CPIC_API_BASE}/drug"
    try:
        data = _http.get(
            url,
            params={"name": f"eq.{drug_name.lower()}", "select": "drugid", "limit": 1},
        ).json()
    except Exception as exc:
        print(f"  ! drugid lookup failed for {drug_name}: {exc}")
        return None
    if isinstance(data, list) and data and isinstance(data[0], dict):
        return data[0].get("drugid")
    return None


def _fetch_recommendations(drugid: str):
    url = f"{CPIC_API_BASE}/recommendation"
    try:
        data = _http.get(
            url,
            params={
                "drugid": f"eq.{drugid}",
                "select": "phenotypes,implications,drugrecommendation,classification,population",
                "limit": 200,
            },
        ).json()
    except Exception as exc:
        print(f"  ! recommendations fetch failed for {drugid}: {exc}")
        return None
    if isinstance(data, list):
        return data
    return None


def main() -> int:
    drugs = _drug_names_from_bundle()
    print(f"Bundled drugs to cache: {len(drugs)}")

    cache = {}
    misses = []
    for drug in drugs:
        print(f"- {drug}")
        drugid = _resolve_drugid(drug)
        if not drugid:
            print("  (no drugid; CPIC doesn't index this drug by that name)")
            misses.append(drug)
            time.sleep(SLEEP_BETWEEN_REQUESTS_S)
            continue
        time.sleep(SLEEP_BETWEEN_REQUESTS_S)
        recs = _fetch_recommendations(drugid)
        if recs is None:
            misses.append(drug)
            time.sleep(SLEEP_BETWEEN_REQUESTS_S)
            continue
        cache[drug.lower()] = {
            "drugid": drugid,
            "recommendations": recs,
        }
        print(f"  OK drugid={drugid} recs={len(recs)}")
        time.sleep(SLEEP_BETWEEN_REQUESTS_S)

    payload = {
        "version": "1",
        "source": CPIC_API_BASE,
        "generated_at": datetime.datetime.utcnow().isoformat() + "Z",
        "drug_count": len(cache),
        "misses": misses,
        "drugs": cache,
    }
    OUTPUT_PATH.write_text(
        json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )
    print(f"\nWrote {OUTPUT_PATH} ({len(cache)} drugs cached, {len(misses)} misses)")
    if misses:
        print("Misses (no CPIC entry by exact name):", ", ".join(misses))
    return 0


if __name__ == "__main__":
    sys.exit(main())

```

### src/parser.worker.js

```javascript
// DoseDNA parser worker
//
// PRIVACY CONTRACT:
//   - The raw DNA file text enters this worker via postMessage and NEVER leaves.
//   - We post back ONLY:
//       { type: "result", genotypes: { <targetRsId>: "XY", ... }, meta: {...} }
//       { type: "error",  message: string }
//   - `genotypes` contains only rsIDs in TARGET_RSIDS. Non-target rows are
//     discarded immediately. We never echo file contents, line numbers, or
//     non-target variants back to the main thread.

const TARGET_RSIDS = new Set([
  "rs4244285",   // CYP2C19 *2
  "rs4986893",   // CYP2C19 *3
  "rs12248560",  // CYP2C19 *17
  "rs1799853",   // CYP2C9 *2
  "rs1057910",   // CYP2C9 *3
  "rs9923231",   // VKORC1
  "rs4149056",   // SLCO1B1
  "rs1800462",   // TPMT *2
  "rs1800460",   // TPMT *3B
  "rs1142345",   // TPMT *3C
]);

const VALID_BASES = new Set(["A", "C", "G", "T"]);

function isValidGenotype(g) {
  if (!g || g.length !== 2) return false;
  return VALID_BASES.has(g[0]) && VALID_BASES.has(g[1]);
}

function detectProviderFromHeader(headerText) {
  // Returns { provider, chip_hint } based on comment header text.
  let provider = "unknown";
  let chip_hint = "unknown";

  const lower = headerText.toLowerCase();
  if (lower.includes("ancestrydna") || lower.includes("ancestry.com")) {
    provider = "AncestryDNA";
  } else if (lower.includes("23andme")) {
    provider = "23andMe";
  }

  // Chip version hint — look for v3/v4/v5, GSA, OmniExpress, etc.
  const chipMatch = headerText.match(/\b(v[1-9][0-9]?|GSA|OmniExpress)\b/i);
  if (chipMatch) {
    chip_hint = chipMatch[1];
  }

  return { provider, chip_hint };
}

function parse(fileText) {
  const lines = fileText.split(/\r?\n/);

  // First pass: collect header comments and find first data line for
  // column-count fallback detection + validation.
  const headerParts = [];
  let firstDataCols = 0;
  let sawRsidRow = false;

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    if (!line) continue;
    if (line.charCodeAt(0) === 35 /* '#' */) {
      headerParts.push(line);
      continue;
    }
    // First non-empty, non-comment line: use it for column-count detection.
    const cols = line.split("\t");
    firstDataCols = cols.length;
    if (/^rs\d+/.test(cols[0])) {
      sawRsidRow = true;
    }
    break;
  }

  // Validation heuristic: must have at least one comment header line AND
  // at least one rsID-looking row somewhere.
  if (headerParts.length === 0 || !sawRsidRow) {
    // Cheap second check: scan ahead a bit for any rs<digits> row in case
    // the first data line happened to be malformed.
    if (headerParts.length === 0) {
      throw new Error(
        "This doesn't look like a consumer DNA file (no rsID rows found)."
      );
    }
    if (!sawRsidRow) {
      let found = false;
      for (let i = 0; i < lines.length; i++) {
        const line = lines[i];
        if (!line || line.charCodeAt(0) === 35) continue;
        const first = line.split("\t", 1)[0];
        if (/^rs\d+/.test(first)) {
          found = true;
          break;
        }
      }
      if (!found) {
        throw new Error(
          "This doesn't look like a consumer DNA file (no rsID rows found)."
        );
      }
    }
  }

  const headerText = headerParts.join("\n");
  let { provider, chip_hint } = detectProviderFromHeader(headerText);

  // Column-count fallback: 4 cols = 23andMe-style, 5 cols = Ancestry-style.
  if (provider === "unknown") {
    if (firstDataCols === 5) provider = "AncestryDNA";
    else if (firstDataCols === 4) provider = "23andMe";
  }

  // Decide whether genotype is one column or two based on provider, with
  // column-count as a safety net per-row.
  const ancestryStyle = provider === "AncestryDNA";

  const genotypes = Object.create(null);
  let total_lines = 0;
  let matched_count = 0;
  let no_call_count = 0;

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    if (!line) continue;
    if (line.charCodeAt(0) === 35 /* '#' */) continue;

    total_lines++;

    // Cheap pre-filter: line starts with "rs" (standard 23andMe/Ancestry) OR
    // contains "_rs" anywhere (gene-prefixed synthetic test files like
    // "CYP2C19_rs4244285"). Real consumer DNA exports always use the bare
    // form; the underscore-prefixed form is generated by some test toolchains
    // and is worth tolerating so it doesn't silently skip every line.
    if (line.charCodeAt(0) !== 114 /* 'r' */ && !line.includes("_rs")) continue;

    const tab1 = line.indexOf("\t");
    if (tab1 === -1) continue;
    const firstCol = line.substring(0, tab1);
    // Accept either bare "rs4244285" or gene-prefixed "CYP2C19_rs4244285".
    // The capture group always pulls the canonical bare rsID.
    const rsidMatch = firstCol.match(/(?:^|_)(rs\d+)$/);
    if (!rsidMatch) continue;
    const rsid = rsidMatch[1];

    if (!TARGET_RSIDS.has(rsid)) continue;
    // Keep FIRST occurrence only.
    if (rsid in genotypes) continue;

    const cols = line.split("\t");

    let genotype;
    if (ancestryStyle || cols.length === 5) {
      // ─── AncestryDNA branch ──────────────────────────────────────────────
      // PENDING REAL-FILE VALIDATION: implemented strictly from BUILD_SPEC §9
      // ("rsid<TAB>chromosome<TAB>position<TAB>allele1<TAB>allele2"). The spec
      // tags this with `[CONFIRM AT BUILD against a real Ancestry file]`. We
      // do NOT have a real AncestryDNA export to test against; if a real file
      // turns out to have an unexpected quirk (extra column, different
      // no-call sentinel, etc.) update this branch and the tests in
      // tests/parser.test.mjs accordingly. Do not invent format quirks here.
      //
      // No-calls per spec are handled downstream by isValidGenotype():
      //   "00" (Ancestry per-allele no-call → concat "00"), "DD"/"II" (indel
      //   markers), or any non-ACGT letter all fail validation and contribute
      //   to "not determined," never to "normal."
      // rsid, 
[truncated — 1250 more characters]
```

### src/pgx.js

```javascript
// pgx.js — deterministic variant → diplotype → phenotype → drug guidance.
//
// PRIVACY: This module receives genotype values from the parser worker and
// produces gene/phenotype/drug strings ONLY. No genotype value, rsID, or
// diplotype is ever returned, logged, or shared outside this module.
//
// HONESTY (BUILD_SPEC §7-§8, the load-bearing rule): missing or no-called
// positions are NEVER silently treated as reference. Instead, we enumerate
// every possible assignment over unknown positions; only when every assignment
// yields the same phenotype do we report it (the "phenotype-if-invariant"
// rule). Anything else returns "Not determined" with coverage_state=partial.
//
// EXPORTS:
//   buildEngine(genesData, drugsData) → { genotypesToResults }   (sync, testable)
//   genotypesToResults(genotypes) → Promise<Result[]>            (browser default)

import { fetchBundledData } from "./data/_loader.js";

const COVERAGE = {
  CONFIDENT: "confident",
  PARTIAL: "partial",
  NOT_CALLABLE: "not-callable",
};

const NOT_DETERMINED = "Not determined";

const COMPLEMENT = { A: "T", T: "A", C: "G", G: "C" };

function complementGenotype(genotype) {
  let out = "";
  for (const b of genotype) out += COMPLEMENT[b] || b;
  return out;
}

// Try plus-strand match first, then minus. Returns { strand, altCount } or null.
function decodeGenotype(genotype, ref, alt) {
  const a = genotype[0];
  const b = genotype[1];
  if ((a === ref || a === alt) && (b === ref || b === alt)) {
    return {
      strand: "plus",
      altCount: (a === alt) + (b === alt),
      plusGenotype: genotype,
    };
  }
  const refC = COMPLEMENT[ref];
  const altC = COMPLEMENT[alt];
  if ((a === refC || a === altC) && (b === refC || b === altC)) {
    return {
      strand: "minus",
      altCount: (a === altC) + (b === altC),
      plusGenotype: complementGenotype(genotype),
    };
  }
  return null;
}

function coverageFor(missing, detected) {
  if (missing === 0) return COVERAGE.CONFIDENT;
  if (detected === 0) return COVERAGE.NOT_CALLABLE;
  return COVERAGE.PARTIAL;
}

// Reduce a set of candidate phenotypes to a single result.
// One distinct phenotype → confident call. Anything else → Not determined.
function collapsePhenotypes(set, cov) {
  if (set.size === 1) {
    const only = set.values().next().value;
    if (only === NOT_DETERMINED) return { phenotype: NOT_DETERMINED, coverage_state: cov };
    return { phenotype: only, coverage_state: cov };
  }
  return { phenotype: NOT_DETERMINED, coverage_state: cov };
}

// ─── diplotype engine (CYP2C19) ──────────────────────────────────────────────
// For each missing position we enumerate 0/1/2 alt-count assignments and
// compute the resulting diplotype. The phenotype is reported only if every
// reachable assignment maps to the same one (spec §7's phenotype-if-invariant).
function evalDiplotype(spec, genotypes) {
  const def = spec.default_allele || "*1";
  const known = [];   // {allele, altCount} for variants we observed
  const unknown = []; // {allele} for variants whose position is missing/no-call
  let missing = 0;
  let detected = 0;

  for (const v of spec.variants) {
    const g = genotypes[v.rsid];
    if (!g) { missing++; unknown.push(v); continue; }
    const d = decodeGenotype(g, v.ref, v.alt);
    if (!d) { missing++; unknown.push(v); continue; }
    detected++;
    if (d.altCount > 0) known.push({ allele: v.allele, altCount: d.altCount });
  }

  const cov = coverageFor(missing, detected);
  if (cov === COVERAGE.NOT_CALLABLE) {
    return { phenotype: NOT_DETERMINED, coverage_state: cov };
  }

  const phenotypes = new Set();
  const counts = new Array(unknown.length).fill(0);

  function tryAssignment() {
    const slots = [def, def];
    for (const k of known) {
      for (let i = 0; i < k.altCount; i++) {
        const slot = slots.indexOf(def);
        if (slot === -1) return null; // 3+ alts in 2 chromosomes → biologically impossible
        slots[slot] = k.allele;
      }
    }
    for (let u = 0; u < unknown.length; u++) {
      for (let i = 0; i < counts[u]; i++) {
        const slot = slots.indexOf(def);
        if (slot === -1) return null;
        slots[slot] = unknown[u].allele;
      }
    }
    const k1 = `${slots[0]}/${slots[1]}`;
    const k2 = `${slots[1]}/${slots[0]}`;
    return spec.diplotype_to_phenotype[k1] || spec.diplotype_to_phenotype[k2] || null;
  }

  function iterate(i) {
    if (i === unknown.length) {
      const p = tryAssignment();
      if (p) phenotypes.add(p);
      return;
    }
    for (let c = 0; c <= 2; c++) {
      counts[i] = c;
      iterate(i + 1);
    }
  }
  iterate(0);

  return collapsePhenotypes(phenotypes, cov);
}

// ─── activity-score engine (CYP2C9) ──────────────────────────────────────────
// For each missing position we enumerate possible alt counts (0/1/2) and
// compute the resulting activity score. Only confident when every reachable
// total maps to the same phenotype.
function evalActivityScore(spec, genotypes) {
  const defAct = spec.default_activity_value ?? 1.0;
  let baseActivity = 2 * defAct;
  const unknownDeltas = [];
  let missing = 0;
  let detected = 0;

  for (const v of spec.variants) {
    const delta = (v.activity_value ?? 0) - defAct;
    const g = genotypes[v.rsid];
    if (!g) { missing++; unknownDeltas.push(delta); continue; }
    const d = decodeGenotype(g, v.ref, v.alt);
    if (!d) { missing++; unknownDeltas.push(delta); continue; }
    detected++;
    baseActivity += d.altCount * delta;
  }

  const cov = coverageFor(missing, detected);
  if (cov === COVERAGE.NOT_CALLABLE) {
    return { phenotype: NOT_DETERMINED, coverage_state: cov };
  }

  const phenotypes = new Set();
  const counts = new Array(unknownDeltas.length).fill(0);

  function iterate(i) {
    if (i === unknownDeltas.length) {
      let a = baseActivity;
      for (let u = 0; u < unknownDeltas.length; u++) a += counts[u] * unknownDeltas[u];
      const key = a.toFixed(1);
      const p = spec.activity_score_
[truncated — 5014 more characters]
```

### src/data/_loader.js

```javascript
// Browser-only loader for the bundled gene/drug JSON. Kept out of pgx.js so
// Node tests can import buildEngine() without triggering fetch.

const GENES_URL = new URL("./genes.json", import.meta.url);
const DRUGS_URL = new URL("./drugs.json", import.meta.url);

export async function fetchBundledData() {
  const [genes, drugs] = await Promise.all([
    fetch(GENES_URL).then((r) => r.json()),
    fetch(DRUGS_URL).then((r) => r.json()),
  ]);
  return { genes, drugs };
}

```

[4 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]