Skip to content

Commit bf8764b

Browse files
authored
Merge pull request #60 from AdaWorldAPI/claude/charming-johnson-ufstpw
feat(fma): /fma-body organ search + zoom + clinical NARS reasoning
2 parents 03e8982 + 0242f7a commit bf8764b

7 files changed

Lines changed: 1244 additions & 95 deletions

File tree

cockpit/public/fma_body.mesh.nodes.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

cockpit/src/FmaBody.tsx

Lines changed: 154 additions & 91 deletions
Large diffs are not rendered by default.
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
//! Clinical NARS reasoning — the FMA `/fma-body` viewer's "reason about this organ"
2+
//! panel, backed by the REAL q2 NARS (`lance_graph_planner::nars::truth::TruthValue::
3+
//! deduction`, the same canonical algebra `graph_engine::nars_deduction` uses).
4+
//!
5+
//! A scenario {organ, condition, medication, labs} is compiled into a small clinical
6+
//! graph of `(subject --rel--> object, NarsTruth)` statements drawn from a hand-authored
7+
//! rule KB (condition→effects, drug→properties, (effect×property)→risk, lab→effect). NARS
8+
//! 2-hop deduction `A→B, B→C ⊢ A→C` then chains e.g. `acetaminophen → hepatically_metabolized
9+
//! → drug_accumulation_toxicity ⊢ acetaminophen → drug_accumulation_toxicity` with a derived
10+
//! truth value. Returns the inferences (frequency/confidence) + a plain-language summary.
11+
//!
12+
//! DEMO ONLY — a NARS reasoning illustration over a tiny rule set, NOT medical advice. The
13+
//! frontend shows that disclaimer in-view.
14+
15+
use lance_graph_contract::exploration::NarsTruth;
16+
use lance_graph_planner::nars::truth::TruthValue;
17+
use serde::Serialize;
18+
19+
/// One clinical statement: `subject --relation--> object` carrying a NARS truth value.
20+
#[derive(Clone)]
21+
struct Stmt {
22+
s: String,
23+
rel: String,
24+
o: String,
25+
truth: NarsTruth,
26+
}
27+
28+
/// A derived (or asserted) clinical inference, wire-serialized with `truth_f`/`truth_c`
29+
/// to match the cockpit's existing NARS JSON convention (see graph_engine).
30+
#[derive(Clone)]
31+
pub struct CliInference {
32+
s: String,
33+
rel: String,
34+
o: String,
35+
kind: &'static str, // "Asserted" | "Deduction"
36+
truth: NarsTruth,
37+
via: Vec<String>,
38+
}
39+
40+
impl Serialize for CliInference {
41+
fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
42+
use serde::ser::SerializeStruct;
43+
let mut st = ser.serialize_struct("CliInference", 7)?;
44+
st.serialize_field("source", &self.s)?;
45+
st.serialize_field("relation", &self.rel)?;
46+
st.serialize_field("target", &self.o)?;
47+
st.serialize_field("inference_type", &self.kind)?;
48+
st.serialize_field("truth_f", &self.truth.frequency)?;
49+
st.serialize_field("truth_c", &self.truth.confidence)?;
50+
st.serialize_field("via", &self.via)?;
51+
st.end()
52+
}
53+
}
54+
55+
/// Canonical NARS deduction `A→B, B→C ⊢ A→C` via the planner's `TruthValue::deduction`
56+
/// (`f = f1·f2, c = f1·f2·c1·c2`) — identical bridge to `graph_engine::nars_deduction`.
57+
fn nars_deduction(ab: &NarsTruth, bc: &NarsTruth) -> NarsTruth {
58+
let r = TruthValue::new(ab.frequency, ab.confidence)
59+
.deduction(&TruthValue::new(bc.frequency, bc.confidence));
60+
NarsTruth::new(r.frequency, r.confidence)
61+
}
62+
63+
fn norm(s: &str) -> String {
64+
s.trim().to_lowercase().replace([' ', '-'], "_")
65+
}
66+
67+
// ── the rule KB (compact clinical demo) ───────────────────────────────────────────────
68+
// condition → physiological effects it induces.
69+
fn condition_effects(cond: &str) -> &'static [(&'static str, f32, f32)] {
70+
match cond {
71+
"cirrhosis" => &[
72+
("impaired_hepatic_clearance", 0.9, 0.85),
73+
("coagulopathy", 0.75, 0.8),
74+
("portal_hypertension", 0.7, 0.75),
75+
],
76+
"hepatitis" => &[
77+
("hepatic_inflammation", 0.85, 0.8),
78+
("impaired_hepatic_clearance", 0.6, 0.7),
79+
],
80+
"ckd" | "renal_failure" | "chronic_kidney_disease" => {
81+
&[("impaired_renal_clearance", 0.9, 0.85)]
82+
}
83+
"heart_failure" => &[
84+
("renal_hypoperfusion", 0.7, 0.75),
85+
("fluid_overload", 0.8, 0.8),
86+
],
87+
_ => &[],
88+
}
89+
}
90+
// medication → pharmacologic properties.
91+
fn drug_properties(drug: &str) -> &'static [(&'static str, f32, f32)] {
92+
match drug {
93+
"acetaminophen" | "paracetamol" => &[
94+
("hepatically_metabolized", 0.95, 0.9),
95+
("hepatotoxic_in_overdose", 0.7, 0.8),
96+
],
97+
"ibuprofen" | "naproxen" | "nsaid" => &[
98+
("renally_cleared", 0.8, 0.85),
99+
("gi_bleed_propensity", 0.65, 0.75),
100+
("nephrotoxic", 0.6, 0.7),
101+
],
102+
"warfarin" => &[
103+
("hepatically_metabolized", 0.9, 0.85),
104+
("anticoagulant", 0.95, 0.9),
105+
],
106+
"metformin" => &[
107+
("renally_cleared", 0.9, 0.9),
108+
("lactic_acidosis_if_accumulated", 0.6, 0.75),
109+
],
110+
_ => &[],
111+
}
112+
}
113+
// (active effect × drug property) → the risk it produces. property→risk is the edge NARS
114+
// chains through (drug → property → risk).
115+
fn risk_rule(effect: &str, property: &str) -> Option<(&'static str, f32, f32)> {
116+
match (effect, property) {
117+
("impaired_hepatic_clearance", "hepatically_metabolized") => {
118+
Some(("drug_accumulation_toxicity", 0.85, 0.8))
119+
}
120+
("impaired_renal_clearance", "renally_cleared") => {
121+
Some(("drug_accumulation_toxicity", 0.85, 0.8))
122+
}
123+
("coagulopathy", "anticoagulant") => Some(("major_bleeding_risk", 0.9, 0.85)),
124+
("coagulopathy", "gi_bleed_propensity") => Some(("gi_hemorrhage_risk", 0.85, 0.8)),
125+
("portal_hypertension", "gi_bleed_propensity") => Some(("variceal_bleed_risk", 0.8, 0.8)),
126+
("renal_hypoperfusion", "nephrotoxic") => Some(("acute_kidney_injury_risk", 0.85, 0.8)),
127+
("impaired_renal_clearance", "lactic_acidosis_if_accumulated") => {
128+
Some(("lactic_acidosis_risk", 0.8, 0.8))
129+
}
130+
_ => None,
131+
}
132+
}
133+
// lab (name, abnormal flag) → the effect it asserts/reinforces.
134+
fn lab_effect(name: &str, flag: &str) -> Option<(&'static str, f32, f32)> {
135+
match (name, flag) {
136+
("inr", "high") => Some(("coagulopathy", 0.85, 0.85)),
137+
("bilirubin", "high") => Some(("impaired_hepatic_clearance", 0.8, 0.8)),
138+
("albumin", "low") => Some(("hepatic_synthetic_dysfunction", 0.7, 0.75)),
139+
("creatinine", "high") => Some(("impaired_renal_clearance", 0.88, 0.85)),
140+
("egfr", "low") => Some(("impaired_renal_clearance", 0.88, 0.85)),
141+
("platelets", "low") => Some(("coagulopathy", 0.6, 0.7)),
142+
_ => None,
143+
}
144+
}
145+
146+
#[derive(serde::Deserialize)]
147+
pub struct LabValue {
148+
pub name: String,
149+
#[serde(default)]
150+
pub flag: String, // "high" | "low" | "normal"
151+
}
152+
153+
#[derive(serde::Deserialize)]
154+
pub struct ClinicalScenario {
155+
#[serde(default)]
156+
pub organ: String,
157+
#[serde(default)]
158+
pub condition: String,
159+
#[serde(default)]
160+
pub medication: String,
161+
#[serde(default)]
162+
pub labs: Vec<LabValue>,
163+
}
164+
165+
/// Compile the scenario → clinical statements, run 2-hop NARS deduction, return inferences.
166+
fn reason(sc: &ClinicalScenario) -> (Vec<CliInference>, Vec<String>) {
167+
let (organ, cond, drug) = (norm(&sc.organ), norm(&sc.condition), norm(&sc.medication));
168+
let mut stmts: Vec<Stmt> = Vec::new();
169+
let mut effects: Vec<String> = Vec::new();
170+
let push = |v: &mut Vec<Stmt>, s: &str, rel: &str, o: &str, f: f32, c: f32| {
171+
v.push(Stmt {
172+
s: s.into(),
173+
rel: rel.into(),
174+
o: o.into(),
175+
truth: NarsTruth::new(f, c),
176+
});
177+
};
178+
179+
// organ --has--> condition
180+
if !organ.is_empty() && !cond.is_empty() {
181+
push(&mut stmts, &organ, "has", &cond, 1.0, 0.9);
182+
}
183+
// condition --induces--> effect
184+
for &(e, f, c) in condition_effects(&cond) {
185+
push(&mut stmts, &cond, "induces", e, f, c);
186+
effects.push(e.to_string());
187+
}
188+
// lab --indicates--> effect (reinforces / asserts)
189+
for lab in &sc.labs {
190+
let (ln, lf) = (norm(&lab.name), norm(&lab.flag));
191+
if let Some((e, f, c)) = lab_effect(&ln, &lf) {
192+
push(&mut stmts, &format!("{ln}_{lf}"), "indicates", e, f, c);
193+
if !effects.contains(&e.to_string()) {
194+
effects.push(e.to_string());
195+
}
196+
}
197+
}
198+
// medication --is--> property ; property --confers--> risk (only for ACTIVE effects)
199+
for &(p, f, c) in drug_properties(&drug) {
200+
if !drug.is_empty() {
201+
push(&mut stmts, &drug, "is", p, f, c);
202+
}
203+
for e in &effects {
204+
if let Some((risk, rf, rc)) = risk_rule(e, p) {
205+
push(&mut stmts, p, "confers", risk, rf, rc);
206+
}
207+
}
208+
}
209+
210+
// assert + deduce
211+
let mut out: Vec<CliInference> = stmts
212+
.iter()
213+
.map(|s| CliInference {
214+
s: s.s.clone(),
215+
rel: s.rel.clone(),
216+
o: s.o.clone(),
217+
kind: "Asserted",
218+
truth: s.truth,
219+
via: vec![],
220+
})
221+
.collect();
222+
223+
// 2-hop deduction A→B, B→C ⊢ A→C
224+
let existing: std::collections::HashSet<(String, String)> =
225+
stmts.iter().map(|s| (s.s.clone(), s.o.clone())).collect();
226+
for ab in &stmts {
227+
for bc in &stmts {
228+
if ab.o == bc.s && ab.s != bc.o && !existing.contains(&(ab.s.clone(), bc.o.clone())) {
229+
let t = nars_deduction(&ab.truth, &bc.truth);
230+
if t.confidence >= 0.1 {
231+
out.push(CliInference {
232+
s: ab.s.clone(),
233+
rel: bc.rel.clone(),
234+
o: bc.o.clone(),
235+
kind: "Deduction",
236+
truth: t,
237+
via: vec![ab.o.clone()],
238+
});
239+
}
240+
}
241+
}
242+
}
243+
244+
// plain-language summary: strongest derived risks (expectation = c·(f−0.5)+0.5).
245+
let mut derived: Vec<&CliInference> = out
246+
.iter()
247+
.filter(|i| i.kind == "Deduction" && (i.o.contains("risk") || i.o.contains("toxicity")))
248+
.collect();
249+
derived.sort_by(|a, b| {
250+
let ex = |i: &CliInference| i.truth.confidence * (i.truth.frequency - 0.5) + 0.5;
251+
ex(b)
252+
.partial_cmp(&ex(a))
253+
.unwrap_or(std::cmp::Ordering::Equal)
254+
});
255+
let mut summary: Vec<String> = derived
256+
.iter()
257+
.take(4)
258+
.map(|i| {
259+
let pretty = |s: &str| s.replace('_', " ");
260+
format!(
261+
"{} → {} (f={:.2}, c={:.2}, via {})",
262+
pretty(&i.s),
263+
pretty(&i.o),
264+
i.truth.frequency,
265+
i.truth.confidence,
266+
i.via.join(", ")
267+
)
268+
})
269+
.collect();
270+
if summary.is_empty() {
271+
summary.push("No risk chain derived from the rule KB for this scenario.".into());
272+
}
273+
(out, summary)
274+
}
275+
276+
/// `POST /api/clinical/reason` — body `{organ, condition, medication, labs:[{name,flag}]}`.
277+
pub async fn clinical_reason_handler(
278+
axum::Json(sc): axum::Json<ClinicalScenario>,
279+
) -> axum::Json<serde_json::Value> {
280+
let (inferences, summary) = reason(&sc);
281+
let asserted = inferences.iter().filter(|i| i.kind == "Asserted").count();
282+
let derived = inferences.len() - asserted;
283+
axum::Json(serde_json::json!({
284+
"organ": sc.organ,
285+
"condition": sc.condition,
286+
"medication": sc.medication,
287+
"asserted": asserted,
288+
"derived": derived,
289+
"inferences": inferences,
290+
"summary": summary,
291+
"engine": "lance_graph_planner::nars::truth::TruthValue::deduction (f=f1·f2, c=f1·f2·c1·c2)",
292+
"disclaimer": "NARS reasoning DEMO over a small rule KB — not medical advice.",
293+
}))
294+
}
295+
296+
#[cfg(test)]
297+
mod tests {
298+
use super::*;
299+
#[test]
300+
fn cirrhosis_acetaminophen_chains_to_accumulation() {
301+
let sc = ClinicalScenario {
302+
organ: "liver".into(),
303+
condition: "cirrhosis".into(),
304+
medication: "acetaminophen".into(),
305+
labs: vec![LabValue {
306+
name: "inr".into(),
307+
flag: "high".into(),
308+
}],
309+
};
310+
let (inf, summary) = reason(&sc);
311+
// deduction must surface acetaminophen → drug_accumulation_toxicity via hepatically_metabolized
312+
assert!(
313+
inf.iter().any(|i| i.s == "acetaminophen"
314+
&& i.o == "drug_accumulation_toxicity"
315+
&& i.kind == "Deduction"),
316+
"expected acetaminophen→drug_accumulation_toxicity; got {:?}",
317+
inf.iter()
318+
.map(|i| format!("{}→{}", i.s, i.o))
319+
.collect::<Vec<_>>()
320+
);
321+
assert!(!summary.is_empty());
322+
}
323+
}

crates/cockpit-server/src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ use tower_http::cors::CorsLayer;
2929

3030
mod openai;
3131
mod graph_engine;
32+
mod clinical;
3233
mod osint_gotham;
3334
mod scene_player;
3435
mod shader_stream;
@@ -168,6 +169,8 @@ async fn main() {
168169
.route("/api/graph/snapshot", get(graph_engine::graph_snapshot_handler))
169170
.route("/api/graph/infer", post(graph_engine::nars_infer_handler))
170171
.route("/api/graph/health", get(graph_engine::graph_health_handler))
172+
// Clinical NARS reasoning for the /fma-body organ panel (real TruthValue::deduction)
173+
.route("/api/clinical/reason", post(clinical::clinical_reason_handler))
171174
// OSINT domain (classid 0x0700): the harvest as a CANON family-basin graph
172175
// (round→anchor basins, GUID-v2 tail), displayed via the OGAR ClassView.
173176
.route("/api/graph/osint", get(osint_gotham::osint_graph_handler))

0 commit comments

Comments
 (0)