Skip to content

Commit 4fecece

Browse files
authored
Merge pull request #63 from AdaWorldAPI/claude/cpic-cockpit
/cpic pharmacogenomics cockpit (endpoint + page) over a shared reason()
2 parents 62f4eaa + ee1196d commit 4fecece

11 files changed

Lines changed: 813 additions & 281 deletions

File tree

Cargo.lock

Lines changed: 11 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ exclude = [
2020
# in the same workspace graph. See
2121
# claude-notes/plans/2026-04-20-wasm-shim-merge.md.
2222
"crates/tree-sitter-language-wasm-shim",
23+
# cpic — standalone pharmacogenomics crate (its own [workspace], serde +
24+
# serde_json only). cockpit-server path-depends on it for the /cpic panel;
25+
# excluding it here makes cargo treat it as a separate workspace root so the
26+
# cross-boundary path dep resolves (else: "multiple workspace roots found").
27+
"cpic",
2328
]
2429
resolver = "2"
2530

cockpit/src/CpicCockpit.tsx

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
// /cpic — CPIC pharmacogenomics cockpit (gene-first), additive alongside /fma-body.
2+
//
3+
// A scenario {gene, diplotype|phenotype, drug} is posted to POST /api/cpic/reason, which runs
4+
// the standalone `cpic` crate's reason() over the REAL published CPIC tables (allele,
5+
// gene_result, drug, pair, guideline, recommendation). It resolves a phenotype and chains
6+
// diplotype → phenotype → recommendation by 2-hop NARS deduction with CPIC-authoritative
7+
// confidence (classification → f, pair cpiclevel → c). Each chain node carries its routable
8+
// (part_of:is_a) GUID prefix — the same canonical 16-byte NodeGuid the rest of the stack uses.
9+
//
10+
// POC over published CPIC rules — NOT clinical decision support.
11+
import { useEffect, useMemo, useState } from 'react';
12+
13+
interface ChainNode {
14+
role: string; // "diplotype" | "phenotype" | "recommendation"
15+
label: string;
16+
guid: string;
17+
}
18+
19+
interface Outcome {
20+
gene: string;
21+
input: string;
22+
drug: string;
23+
resolved: boolean;
24+
phenotype: string | null;
25+
how: string | null;
26+
chain: ChainNode[];
27+
classification: string | null;
28+
cpic_level: string | null;
29+
truth_f: number;
30+
truth_c: number;
31+
truth_exp: number;
32+
recommendation: string | null;
33+
flags: string[];
34+
disclaimer: string;
35+
}
36+
37+
interface Catalog {
38+
genes: string[];
39+
drugs: string[];
40+
}
41+
42+
interface Scenario {
43+
gene: string;
44+
input: string;
45+
drug: string;
46+
}
47+
48+
// the four `reason` CLI demos: clean 2-hop, direct 1-hop, multi-gene flag, complex-guideline flag.
49+
const EXAMPLES: { label: string; sc: Scenario }[] = [
50+
{ label: 'CYP2C19 *2/*2 · clopidogrel', sc: { gene: 'CYP2C19', input: '*2/*2', drug: 'clopidogrel' } },
51+
{ label: 'TPMT *3A/*3A · azathioprine', sc: { gene: 'TPMT', input: '*3A/*3A', drug: 'azathioprine' } },
52+
{ label: 'HLA-B *57:01 positive · abacavir', sc: { gene: 'HLA-B', input: '*57:01 positive', drug: 'abacavir' } },
53+
{ label: 'CYP2C9 *1/*1 · warfarin (complex)', sc: { gene: 'CYP2C9', input: '*1/*1', drug: 'warfarin' } },
54+
];
55+
56+
const ROLE_COLOR: Record<string, string> = {
57+
diplotype: '#6db3ff',
58+
phenotype: '#7fd9a8',
59+
recommendation: '#ffb86b',
60+
};
61+
62+
function levelColor(level: string | null): string {
63+
switch (level) {
64+
case 'A': return '#35d07f';
65+
case 'B': return '#9ad07f';
66+
case 'C': return '#ffb547';
67+
case 'D': return '#ff8c63';
68+
default: return '#93a9bf';
69+
}
70+
}
71+
72+
export function CpicCockpit() {
73+
const [catalog, setCatalog] = useState<Catalog>({ genes: [], drugs: [] });
74+
const [gene, setGene] = useState('CYP2C19');
75+
const [input, setInput] = useState('*2/*2');
76+
const [drug, setDrug] = useState('clopidogrel');
77+
const [outcome, setOutcome] = useState<Outcome | null>(null);
78+
const [error, setError] = useState<string | null>(null);
79+
const [busy, setBusy] = useState(false);
80+
81+
// pull the gene + drug pick-lists once (used as <datalist> autocomplete; free text still allowed).
82+
useEffect(() => {
83+
let cancelled = false;
84+
fetch('/api/cpic/catalog')
85+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
86+
.then((c: Catalog) => { if (!cancelled) setCatalog(c); })
87+
.catch(() => { /* catalog is optional — the inputs accept free text regardless */ });
88+
return () => { cancelled = true; };
89+
}, []);
90+
91+
async function runReason(sc: Scenario) {
92+
setBusy(true);
93+
setError(null);
94+
setOutcome(null);
95+
try {
96+
const r = await fetch('/api/cpic/reason', {
97+
method: 'POST',
98+
headers: { 'content-type': 'application/json' },
99+
body: JSON.stringify(sc),
100+
});
101+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
102+
setOutcome((await r.json()) as Outcome);
103+
} catch (e) {
104+
setError(`reasoning endpoint unavailable (${e}) — needs the cockpit-server backend deployed`);
105+
} finally {
106+
setBusy(false);
107+
}
108+
}
109+
110+
const canReason = useMemo(
111+
() => gene.trim() !== '' && input.trim() !== '' && drug.trim() !== '' && !busy,
112+
[gene, input, drug, busy],
113+
);
114+
115+
function pickExample(sc: Scenario) {
116+
setGene(sc.gene);
117+
setInput(sc.input);
118+
setDrug(sc.drug);
119+
void runReason(sc);
120+
}
121+
122+
const fieldStyle: React.CSSProperties = {
123+
boxSizing: 'border-box', padding: '8px 10px', borderRadius: 6,
124+
border: '1px solid #2a3242', background: '#0e1219', color: '#cdd9e5',
125+
font: '13px ui-monospace, monospace',
126+
};
127+
const chip: React.CSSProperties = {
128+
padding: '5px 11px', borderRadius: 6, border: '1px solid #2a3242',
129+
background: '#0e1219', color: '#9fb1c4', font: '12px ui-monospace, monospace', cursor: 'pointer',
130+
};
131+
const badge = (bg: string, fg: string): React.CSSProperties => ({
132+
display: 'inline-block', padding: '2px 9px', borderRadius: 999,
133+
background: bg, color: fg, font: '11px ui-monospace, monospace', marginRight: 8,
134+
});
135+
136+
return (
137+
<div style={{ position: 'fixed', inset: 0, background: '#0a0e17', overflow: 'auto', color: '#cdd9e5' }}>
138+
<div style={{ maxWidth: 760, margin: '0 auto', padding: '28px 20px 60px', font: '13px ui-monospace, monospace' }}>
139+
<div style={{ fontSize: 20, color: '#fff', marginBottom: 4 }}>
140+
CPIC pharmacogenomics
141+
</div>
142+
<div style={{ opacity: 0.65, marginBottom: 2 }}>
143+
gene → phenotype → recommendation, chained by NARS deduction over the real CPIC tables.
144+
</div>
145+
<div style={{ opacity: 0.5, fontSize: 11, marginBottom: 18 }}>
146+
confidence is CPIC-authoritative (classification → f, pair cpiclevel → c). Each node shows its
147+
routable (part_of:is_a) GUID. POC over published CPIC rules — <b>not</b> clinical decision support.
148+
</div>
149+
150+
{/* input row */}
151+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-end' }}>
152+
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 130px' }}>
153+
<span style={{ opacity: 0.6, fontSize: 11 }}>gene</span>
154+
<input list="cpic-genes" value={gene} onChange={(e) => setGene(e.target.value)}
155+
placeholder="CYP2C19" style={fieldStyle} />
156+
</label>
157+
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 150px' }}>
158+
<span style={{ opacity: 0.6, fontSize: 11 }}>diplotype / phenotype</span>
159+
<input value={input} onChange={(e) => setInput(e.target.value)}
160+
placeholder="*2/*2" style={fieldStyle} />
161+
</label>
162+
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 150px' }}>
163+
<span style={{ opacity: 0.6, fontSize: 11 }}>drug</span>
164+
<input list="cpic-drugs" value={drug} onChange={(e) => setDrug(e.target.value)}
165+
placeholder="clopidogrel" style={fieldStyle} />
166+
</label>
167+
<button
168+
disabled={!canReason}
169+
onClick={() => runReason({ gene, input, drug })}
170+
style={{
171+
...fieldStyle, cursor: canReason ? 'pointer' : 'not-allowed',
172+
border: '1px solid #3a5f88', background: canReason ? '#16202e' : '#0c1017',
173+
color: canReason ? '#cdd9e5' : '#566', padding: '9px 18px',
174+
}}
175+
>
176+
{busy ? 'reasoning…' : 'reason'}
177+
</button>
178+
<datalist id="cpic-genes">{catalog.genes.map((g) => <option key={g} value={g} />)}</datalist>
179+
<datalist id="cpic-drugs">{catalog.drugs.map((d) => <option key={d} value={d} />)}</datalist>
180+
</div>
181+
182+
{/* example chips */}
183+
<div style={{ display: 'flex', gap: 7, flexWrap: 'wrap', marginTop: 12 }}>
184+
<span style={{ opacity: 0.45, fontSize: 11, alignSelf: 'center' }}>examples:</span>
185+
{EXAMPLES.map((ex) => (
186+
<button key={ex.label} style={chip} disabled={busy} onClick={() => pickExample(ex.sc)}>{ex.label}</button>
187+
))}
188+
</div>
189+
190+
{error && (
191+
<div style={{ marginTop: 18, padding: 12, borderRadius: 8, border: '1px solid #ff637d44', background: '#160e12', color: '#ff8095' }}>
192+
{error}
193+
</div>
194+
)}
195+
196+
{outcome && (
197+
<div style={{ marginTop: 20, background: '#0e1219', border: '1px solid #1c2530', borderRadius: 10, padding: 16 }}>
198+
<div style={{ color: '#fff', fontSize: 15, marginBottom: 2 }}>
199+
{outcome.gene} {outcome.input} <span style={{ opacity: 0.5 }}>+</span> {outcome.drug}
200+
</div>
201+
{outcome.how && <div style={{ opacity: 0.55, fontSize: 11, marginBottom: 12 }}>resolved via {outcome.how}</div>}
202+
203+
{/* the reasoned chain: diplotype → phenotype → recommendation, each with its GUID */}
204+
{outcome.chain.length > 0 && (
205+
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, marginBottom: 14 }}>
206+
{outcome.chain.map((n, i) => (
207+
<div key={`${n.role}-${i}`}>
208+
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '6px 0' }}>
209+
<span style={{ ...badge('#11161f', ROLE_COLOR[n.role] ?? '#9fb1c4'), minWidth: 96, textAlign: 'center' }}>{n.role}</span>
210+
<span style={{ color: '#dbe6f0', flex: '1 1 auto' }}>{n.label}</span>
211+
<span style={{ opacity: 0.5, fontSize: 11 }}>{n.guid}</span>
212+
</div>
213+
{i < outcome.chain.length - 1 && (
214+
<div style={{ marginLeft: 44, color: '#33414f', fontSize: 13, lineHeight: '8px' }}></div>
215+
)}
216+
</div>
217+
))}
218+
</div>
219+
)}
220+
221+
{outcome.resolved ? (
222+
<>
223+
<div style={{ marginBottom: 10 }}>
224+
{outcome.classification && (
225+
<span style={badge('#16202e', '#cdd9e5')}>class: {outcome.classification}</span>
226+
)}
227+
{outcome.cpic_level && (
228+
<span style={badge('#11161f', levelColor(outcome.cpic_level))}>CPIC level {outcome.cpic_level}</span>
229+
)}
230+
<span style={badge('#11161f', '#6db3ff')}>
231+
f={outcome.truth_f.toFixed(3)} c={outcome.truth_c.toFixed(3)} · exp {outcome.truth_exp.toFixed(3)}
232+
</span>
233+
</div>
234+
{outcome.recommendation && (
235+
<div style={{ marginTop: 4, padding: '10px 12px', borderRadius: 8, background: '#101a14', border: '1px solid #1d3326', color: '#bfe9cf' }}>
236+
<span style={{ opacity: 0.6, fontSize: 11 }}>CPIC recommendation</span>
237+
<div style={{ marginTop: 4 }}>{outcome.recommendation}</div>
238+
</div>
239+
)}
240+
</>
241+
) : (
242+
<div style={{ padding: '10px 12px', borderRadius: 8, background: '#1a1410', border: '1px solid #3a2c1c', color: '#ffce96' }}>
243+
no simple phenotype → recommendation — surfaced, not fabricated.
244+
</div>
245+
)}
246+
247+
{/* flags: complexity / multi-gene / unknown-drug warnings CPIC itself raises */}
248+
{outcome.flags.length > 0 && (
249+
<div style={{ marginTop: 12 }}>
250+
{outcome.flags.map((f, i) => (
251+
<div key={i} style={{ color: '#ffb86b', fontSize: 12, marginBottom: 4 }}>{f}</div>
252+
))}
253+
</div>
254+
)}
255+
256+
<div style={{ opacity: 0.4, fontSize: 10, marginTop: 14, borderTop: '1px solid #1c2530', paddingTop: 8 }}>
257+
{outcome.disclaimer}
258+
</div>
259+
</div>
260+
)}
261+
262+
<div style={{ display: 'flex', gap: 16, marginTop: 22, opacity: 0.7 }}>
263+
<a href="/fma-body" style={{ color: '#7fa6c4', textDecoration: 'none' }}>/fma-body →</a>
264+
<a href="/fma" style={{ color: '#7fa6c4', textDecoration: 'none' }}>/fma graph →</a>
265+
<a href="/" style={{ color: '#7fa6c4', textDecoration: 'none' }}>/ cockpit →</a>
266+
</div>
267+
</div>
268+
</div>
269+
);
270+
}

cockpit/src/main.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { TorsoSplat } from './TorsoSplat';
1313
import { TorsoRender } from './TorsoRender';
1414
import { TorsoMap } from './TorsoMap';
1515
import { FmaBody } from './FmaBody';
16+
import { CpicCockpit } from './CpicCockpit';
1617
import { ReasoningPage } from './ReasoningPage';
1718
import { ErrorBoundary } from './components/ErrorBoundary';
1819
import './styles/cockpit.css';
@@ -97,6 +98,11 @@ createRoot(document.getElementById('root')!).render(
9798
LAYER (skin/muscle/organ/skeleton/vessel/nerve buttons) + solid↔transparent.
9899
Additive; reads cockpit/public/fma_body.mesh; never touches /torso* (#57/#58). */}
99100
<Route path="/fma-body" element={<FmaBody />} />
101+
{/* /cpic — CPIC pharmacogenomics cockpit (gene-first): {gene, diplotype, drug}
102+
→ phenotype → recommendation, 2-hop NARS deduction over the real CPIC tables
103+
via POST /api/cpic/reason (the standalone cpic crate). Additive, gene-first
104+
alternative to the organ-first /fma-body. */}
105+
<Route path="/cpic" element={<CpicCockpit />} />
100106
{/* The Palantir JSON-graph cockpit (221 aiwar nodes) stays reachable
101107
at /palantir and as the catch-all for its own sub-routes. */}
102108
<Route path="/palantir" element={<PalantirApp />} />

cpic/Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cpic/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ license = "Apache-2.0"
1616
[workspace]
1717

1818
[dependencies]
19+
serde = { version = "1", features = ["derive"] }
1920
serde_json = "1"
2021

2122
[profile.release]

0 commit comments

Comments
 (0)