diff --git a/Cargo.toml b/Cargo.toml index 8a6f08a..5640bfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,8 @@ members = [ "crates/ogar-class-view", "crates/ogar-render-askama", "crates/ogar-fma-skeleton", + "crates/ogar-fma", + "crates/ogar-cpic", "crates/ogar-adapter-python", "crates/ogar-adapter-csharp", "crates/ogar-auth", diff --git a/crates/ogar-cpic/Cargo.toml b/crates/ogar-cpic/Cargo.toml new file mode 100644 index 0000000..6e40fa2 --- /dev/null +++ b/crates/ogar-cpic/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ogar-cpic" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true +description = "CPIC pharmacogenomics reference surface — the public gene → phenotype → drug-recommendation guideline table (the Genetics domain, 0x0EXX, consumed by q2 /cpic). Resolves a CPIC gene(-pair) + drug to its actionable recommendation + a NARS (frequency, confidence) deduction premise. Pure public reference; never PHI, never a patient genotype." + +[features] +default = [] +serde = ["dep:serde", "ogar-vocab/serde"] + +[dependencies] +ogar-vocab = { path = "../ogar-vocab" } +serde = { workspace = true, optional = true } diff --git a/crates/ogar-cpic/src/lib.rs b/crates/ogar-cpic/src/lib.rs new file mode 100644 index 0000000..1e392db --- /dev/null +++ b/crates/ogar-cpic/src/lib.rs @@ -0,0 +1,304 @@ +//! `ogar-cpic` — the **CPIC pharmacogenomics reference surface**: the public +//! gene → phenotype → drug-recommendation guideline table that q2 `/cpic` +//! chains, and the NARS `(frequency, confidence)` premise it deduces over. +//! +//! # What this is +//! +//! A clinical reasoning layer knows a therapy holds a *join key* into +//! pharmacogenomics — "azathioprine is dosed by TPMT/NUDT15 phenotype". The +//! CPIC guideline itself (which phenotype, what to do about it, how strong the +//! evidence) is **public reference** owned upstream (commitment #3: outside +//! ontology is owned upstream), not something the consumer restates. This +//! crate is the single public place that guideline table lives. +//! +//! A consumer pulls it — `ogar_cpic::resolve("TPMT/NUDT15", "azathioprine")` — +//! and gets the phenotype axis, the actionable recommendation, and a NARS +//! `(f, c)` deduction premise. It never hand-maintains a parallel CPIC table. +//! +//! # This is reference, NOT a genotype +//! +//! Everything here is the **published CPIC guideline** — gene-name, phenotype +//! *axis* (the set of metabolizer statuses that exist), the population-level +//! recommendation. It carries **no patient genotype and no PHI**: a named +//! patient's `TPMT *3A/*3C` call is Health data that stays inside the clinical +//! repo, exactly as the firewall split keeps FMA structure (public) apart from +//! a fracture diagnosis (PHI). This surface tells you *what CPIC says about the +//! gene*; it never tells you *what a person carries*. +//! +//! # Classid routing — the reserved Genetics domain (`0x0EXX`) +//! +//! `ogar_vocab` reserves `ConceptDomain::Genetics` (`0x0EXX`) for CPIC +//! pharmacogenomics, **consumed by q2** — but ships ZERO concept rows there, +//! guarded by an explicit *"do not mint without an operator ruling"* note (the +//! same posture as the OSINT domain). This crate honors that gate: it routes at +//! the **domain** level ([`GENETICS_DOMAIN`] / [`GENETICS_DOMAIN_HI`]) and does +//! NOT mint a concept id into the shared codebook. The committed anchor for the +//! first Genetics concept is the V3 marker form `0x0E01_1000` (CPIC under q2, +//! appid `0x01`; `docs/DISCOVERY-MAP.md` D-CLASSID-CANON-HIGH-FLIP) — minting it +//! is a deliberate, operator-gated follow-up, surfaced not silently taken. +//! Until then a consumer routes CPIC nodes on the reserved domain tag and keys +//! the concrete guideline by its (gene, drug) pair. + +#![warn(missing_docs)] +#![forbid(unsafe_code)] + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +pub use ogar_vocab::ConceptDomain; + +/// The reserved Genetics [`ConceptDomain`] every CPIC node routes on. The +/// domain slot is live in `ogar_vocab` even with zero concept rows, so a +/// consumer can branch on it today. +pub const GENETICS_DOMAIN: ConceptDomain = ConceptDomain::Genetics; + +/// The high byte of the Genetics domain (`0x0E`) — the `id >> 8` a consumer +/// matches when routing a CPIC node from a bare classid. The committed first +/// concept anchor is `0x0E01` (see the module docs); this crate does not mint +/// it. +pub const GENETICS_DOMAIN_HI: u8 = 0x0E; + +/// CPIC recommendation strength for a gene-drug pair — the published guideline +/// classification, which sets how hard the NARS premise leans. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[non_exhaustive] +pub enum Strength { + /// A strong recommendation — the evidence is high and the action clear + /// (e.g. `HLA-B*58:01` positive ⇒ do not use allopurinol). + Strong, + /// A moderate recommendation. + Moderate, + /// An optional recommendation — actionable but weaker evidence. + Optional, +} + +impl Strength { + /// A NARS `(frequency, confidence)` deduction premise for a guideline at + /// this strength — the `(f, c)` q2 `/cpic` chains gene→phenotype→rec over. + /// Frequency is how reliably the phenotype implies the action; confidence + /// tracks the CPIC evidence grade. + #[must_use] + pub const fn nars_truth(self) -> (f64, f64) { + match self { + Strength::Strong => (0.95, 0.92), + Strength::Moderate => (0.85, 0.78), + Strength::Optional => (0.70, 0.60), + } + } +} + +/// A resolved public CPIC guideline — one gene(-pair) × drug row. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct CpicGuideline { + /// The CPIC gene or gene-pair label (e.g. `"TPMT/NUDT15"`, `"HLA-B*58:01"`) + /// — the exact join key a consumer's therapy annotation carries. + pub gene: &'static str, + /// The affected drug (the therapy the guideline doses). + pub drug: &'static str, + /// The phenotype axis CPIC stratifies on — the *set of statuses that + /// exist* (e.g. `"metabolizer status (normal / intermediate / poor)"`). + /// Public reference: the axis, never a patient's position on it. + pub phenotype_axis: &'static str, + /// The actionable population-level recommendation for the affected + /// phenotype(s). + pub recommendation: &'static str, + /// The CPIC recommendation strength. + pub strength: Strength, +} + +impl CpicGuideline { + /// The NARS `(frequency, confidence)` premise for this guideline. + #[must_use] + pub const fn nars_truth(&self) -> (f64, f64) { + self.strength.nars_truth() + } +} + +/// The curated public CPIC guideline table — actionable gene-drug pairs. This +/// is published CPIC reference (Level A / actionable guidelines), not an +/// exhaustive mirror; extended as consumers pull more pairs. +const GUIDELINES: &[CpicGuideline] = &[ + CpicGuideline { + gene: "TPMT/NUDT15", + drug: "azathioprine", + phenotype_axis: "thiopurine metabolizer status (normal / intermediate / poor)", + recommendation: "poor metabolizer ⇒ substantially reduce dose or use an alternative agent \ + (myelosuppression risk); intermediate ⇒ reduce starting dose", + strength: Strength::Strong, + }, + CpicGuideline { + gene: "HLA-B*58:01", + drug: "allopurinol", + phenotype_axis: "HLA-B*58:01 allele carrier status (positive / negative)", + recommendation: "positive ⇒ do NOT prescribe allopurinol (SCAR / SJS-TEN risk); \ + use an alternative urate-lowering therapy (e.g. febuxostat with caution)", + strength: Strength::Strong, + }, + CpicGuideline { + gene: "CYP2C19", + drug: "clopidogrel", + phenotype_axis: "metabolizer status (ultrarapid / normal / intermediate / poor)", + recommendation: "intermediate or poor metabolizer ⇒ use an alternative antiplatelet \ + (prasugrel / ticagrelor) — reduced active-metabolite formation", + strength: Strength::Strong, + }, + CpicGuideline { + gene: "CYP2D6", + drug: "codeine", + phenotype_axis: "metabolizer status (ultrarapid / normal / intermediate / poor)", + recommendation: "ultrarapid ⇒ avoid (morphine toxicity); poor ⇒ avoid (no analgesia); \ + use a non-CYP2D6 opioid alternative", + strength: Strength::Strong, + }, + CpicGuideline { + gene: "SLCO1B1", + drug: "simvastatin", + phenotype_axis: "SLCO1B1 function (normal / decreased / poor)", + recommendation: "decreased or poor function ⇒ lower dose or an alternative statin \ + (myopathy risk)", + strength: Strength::Strong, + }, + CpicGuideline { + gene: "DPYD", + drug: "fluorouracil", + phenotype_axis: "DPD activity (normal / intermediate / poor)", + recommendation: "intermediate ⇒ reduce starting dose; poor ⇒ avoid \ + (severe / fatal fluoropyrimidine toxicity)", + strength: Strength::Strong, + }, + CpicGuideline { + gene: "CYP2C9/VKORC1", + drug: "warfarin", + phenotype_axis: "combined CYP2C9 + VKORC1 dosing genotype", + recommendation: "use the pharmacogenetic dosing algorithm — variant carriers need a \ + lower maintenance dose", + strength: Strength::Moderate, + }, +]; + +/// Resolve a CPIC guideline by its gene(-pair) label and drug. Case-insensitive +/// on ASCII; whitespace-trimmed. `None` = no actionable CPIC guideline for that +/// pair (which a consumer treats exactly as its `cpic_gene = None` loose end). +/// +/// ``` +/// let g = ogar_cpic::resolve("HLA-B*58:01", "allopurinol").unwrap(); +/// assert_eq!(g.strength, ogar_cpic::Strength::Strong); +/// let (f, c) = g.nars_truth(); +/// assert!(f > 0.9 && c > 0.9); +/// ``` +#[must_use] +pub fn resolve(gene: &str, drug: &str) -> Option<&'static CpicGuideline> { + let (gene, drug) = (gene.trim(), drug.trim()); + GUIDELINES + .iter() + .find(|g| g.gene.eq_ignore_ascii_case(gene) && g.drug.eq_ignore_ascii_case(drug)) +} + +/// Resolve by gene(-pair) label alone — the first guideline naming that gene. +#[must_use] +pub fn resolve_gene(gene: &str) -> Option<&'static CpicGuideline> { + let gene = gene.trim(); + GUIDELINES + .iter() + .find(|g| g.gene.eq_ignore_ascii_case(gene)) +} + +/// Every published guideline — for a consumer indexing the whole table once. +#[must_use] +pub fn guidelines() -> &'static [CpicGuideline] { + GUIDELINES +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn genetics_domain_routing_is_live_without_a_mint() { + // The domain tag resolves even though the codebook ships zero Genetics + // concept rows (the reserved-domain posture). + assert_eq!(GENETICS_DOMAIN, ConceptDomain::Genetics); + assert_eq!(GENETICS_DOMAIN_HI, 0x0E); + // The committed first-concept anchor 0x0E01 routes to Genetics, but we + // do NOT mint it here — resolving the domain from the anchor is enough. + assert_eq!( + ogar_vocab::canonical_concept_domain(0x0E01), + ConceptDomain::Genetics, + ); + // And it is genuinely unminted (gate honored). + assert_eq!(ogar_vocab::canonical_concept_name(0x0E01), None); + } + + #[test] + fn medcare_actionable_pairs_resolve() { + // The two actionable pgx pairs medcare-cohorts carries as join keys. + let aza = resolve("TPMT/NUDT15", "azathioprine").unwrap(); + assert_eq!(aza.strength, Strength::Strong); + assert!(aza.recommendation.contains("poor metabolizer")); + + let allo = resolve("HLA-B*58:01", "allopurinol").unwrap(); + assert_eq!(allo.strength, Strength::Strong); + assert!(allo.recommendation.contains("do NOT")); + } + + #[test] + fn resolve_is_case_and_whitespace_insensitive() { + assert!(resolve(" cyp2c19 ", "CLOPIDOGREL").is_some()); + assert!(resolve_gene("dpyd").is_some()); + assert!(resolve("TPMT/NUDT15", "aspirin").is_none()); + assert!(resolve("ABCB1", "digoxin").is_none()); + } + + #[test] + fn nars_truth_leans_with_strength() { + let strong = Strength::Strong.nars_truth(); + let optional = Strength::Optional.nars_truth(); + assert!(strong.0 > optional.0, "strong is higher-frequency"); + assert!(strong.1 > optional.1, "strong is higher-confidence"); + // Bounded probabilities. + for s in [Strength::Strong, Strength::Moderate, Strength::Optional] { + let (f, c) = s.nars_truth(); + assert!((0.0..=1.0).contains(&f) && (0.0..=1.0).contains(&c)); + } + } + + #[test] + fn gene_labels_carry_no_patient_genotype() { + // Reference-not-genotype invariant: a guideline names the phenotype + // *axis* (a status/function/activity/carrier space), never a specific + // patient star-allele diplotype call (a "*3A/*3C" would live in the + // clinical repo, never here). Every axis uses axis vocabulary. + const AXIS_WORDS: [&str; 5] = ["status", "function", "activity", "genotype", "carrier"]; + for g in guidelines() { + let axis = g.phenotype_axis.to_ascii_lowercase(); + assert!( + AXIS_WORDS.iter().any(|w| axis.contains(w)), + "{} phenotype_axis reads like a genotype call, not an axis: {}", + g.gene, + g.phenotype_axis, + ); + // A concrete diplotype call is `*/*`; never present. + assert!( + !axis.contains("*/") && !axis.contains("/*"), + "{} phenotype_axis contains a diplotype call", + g.gene, + ); + } + } + + #[test] + fn gene_drug_pairs_are_unique() { + use std::collections::HashSet; + let mut seen = HashSet::new(); + for g in guidelines() { + assert!( + seen.insert((g.gene, g.drug)), + "duplicate CPIC pair {} / {}", + g.gene, + g.drug, + ); + } + } +} diff --git a/crates/ogar-fma/Cargo.toml b/crates/ogar-fma/Cargo.toml new file mode 100644 index 0000000..74e329d --- /dev/null +++ b/crates/ogar-fma/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ogar-fma" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true +description = "FMA anatomy structure resolver — the public name→structure reference surface (organs / joints / systems / tissues) beyond the bone skeleton. Resolves an anatomical-structure name to its canonical Anatomy classid (0x0AXX) + FMA cross-reference, the key q2 /helix uses to zoom a surfel render. Pure reference; never PHI." + +[features] +default = [] +serde = ["dep:serde", "ogar-vocab/serde"] + +[dependencies] +ogar-vocab = { path = "../ogar-vocab" } +serde = { workspace = true, optional = true } diff --git a/crates/ogar-fma/src/lib.rs b/crates/ogar-fma/src/lib.rs new file mode 100644 index 0000000..9be84c8 --- /dev/null +++ b/crates/ogar-fma/src/lib.rs @@ -0,0 +1,304 @@ +//! `ogar-fma` — the **FMA anatomy structure resolver**: the public +//! name→structure reference surface for organs, joints, systems, and tissues, +//! the soft-tissue complement to the rigid [`ogar-fma-skeleton`] bone atlas. +//! +//! # What this is +//! +//! A consumer (a clinical reasoning layer, an imaging viewer) knows an +//! anatomical site by *name* — "synovial joint", "kidney", "lung". It needs +//! two things that are **public reference**, not PHI: +//! +//! 1. the **canonical Anatomy classid** (`0x0AXX`) the name routes on, so the +//! node addresses into the same partonomy every imaging modality registers +//! against (`ConceptDomain::Anatomy` — deliberately NOT `Health`, per the +//! firewall split in `ogar_vocab`: reference structure is public, a clinical +//! *finding about* it on a named patient is PHI); +//! 2. the **FMA cross-reference** (`FMA:xxxxx`) where confidently known — the +//! key q2 `/helix` uses to zoom a surfel render to the affected structure. +//! +//! This crate is the single public place that mapping lives. A consumer pulls +//! it (`ogar_fma::resolve("synovial joint")`); it never hand-maintains a +//! parallel FMA table nor fabricates ids (the honesty fence below). +//! +//! # Relationship to `ogar-fma-skeleton` +//! +//! [`ogar-fma-skeleton`] is the **bone** atlas — the ~206 rigid clamped +//! convergence anchors, each a `bone` (`0x0A03`) cascade-path node with a +//! Located 3-axis address. This crate is the **non-bone** structures (organs, +//! joints, systems, tissues) that a disease localizes to, keyed by name and +//! routed on the broader Anatomy concepts (`anatomical_structure` `0x0A01` / +//! `joint` `0x0A04`). A [`FmaPartition::Bone`] result defers to the skeleton +//! crate for the spatial address; here it carries only the concept routing. +//! +//! # Honesty fence (inherited from `ogar-fma-skeleton`) +//! +//! FMA numeric ids are filled **only where a canonical, stable FMA id is +//! confidently known** (the same practice the skeleton atlas uses — femur +//! `FMA:9611`, tibia `FMA:12856`). Where not, [`FmaStructure::fma_id`] is +//! `None` — a captured loose end resolved against the q2 `/helix` structure +//! table, never fabricated here. What is rigorous is the *structure*: the +//! name→partition→classid routing and the laterality, which carry regardless +//! of whether the numeric cross-reference is yet pinned. +//! +//! [`ogar-fma-skeleton`]: https://docs.rs/ogar-fma-skeleton + +#![warn(missing_docs)] +#![forbid(unsafe_code)] + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// The partonomic kind of an anatomical structure — the axis that selects +/// which canonical Anatomy concept (`0x0AXX`) the structure routes on. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[non_exhaustive] +pub enum FmaPartition { + /// A whole organ (kidney, lung, pancreas, thyroid gland). Routes on + /// `anatomical_structure` (`0x0A01`) — FMA's universal root kind. + Organ, + /// An articulation between bones (synovial joint, first MTP joint). Routes + /// on `joint` (`0x0A04`). + Joint, + /// An anatomical *system* / branching tree (systemic arterial system, + /// bronchial tree). Routes on `anatomical_structure` (`0x0A01`). + System, + /// A tissue (bone marrow). Routes on `anatomical_structure` (`0x0A01`). + Tissue, + /// A skeletal element. Routes on `bone` (`0x0A03`); the *spatial* address + /// lives in [`ogar-fma-skeleton`](crate), not here. + Bone, +} + +impl FmaPartition { + /// The canonical Anatomy concept id (`0x0AXX`) this partition routes on — + /// the hi-u16 (`domain:concept`) half of a rendered classid. + #[must_use] + pub const fn concept_id(self) -> u16 { + match self { + FmaPartition::Joint => ogar_vocab::class_ids::JOINT, + FmaPartition::Bone => ogar_vocab::class_ids::BONE, + FmaPartition::Organ | FmaPartition::System | FmaPartition::Tissue => { + ogar_vocab::class_ids::ANATOMICAL_STRUCTURE + } + } + } +} + +/// Laterality of a structure — carried so a consumer's per-side finding +/// (a left vs right kidney) addresses the correct instance. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[non_exhaustive] +pub enum Laterality { + /// A single midline / systemic structure (pancreas, systemic arterial system). + Unpaired, + /// A paired structure that a finding may localize to one side (kidney, lung). + Paired, + /// A structure characteristically involved on both sides at once + /// (symmetric small-joint synovitis). + Bilateral, +} + +/// A resolved public FMA anatomical structure — the reference row a consumer +/// pulls to route an anatomical site into the Anatomy partonomy. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct FmaStructure { + /// The canonical structure name (the `/helix` structure-index key). + pub name: &'static str, + /// The FMA numeric cross-reference (`FMA:xxxxx`) where confidently known; + /// `None` = a loose end resolved against `/helix`, never fabricated. + pub fma_id: Option, + /// The partonomic kind — selects the routing concept. + pub partition: FmaPartition, + /// Characteristic laterality. + pub laterality: Laterality, +} + +impl FmaStructure { + /// The canonical Anatomy concept id (`0x0AXX`) this structure routes on. + #[must_use] + pub const fn concept_id(&self) -> u16 { + self.partition.concept_id() + } + + /// The full V3 render classid for this structure under a consumer's app + /// prefix (the lo-u16 render skin): `(concept as u32) << 16 | app_prefix`. + /// The hi-u16 is the shared `domain:concept` canon; the lo-u16 is the + /// per-consumer classview. Mirrors `ogar_vocab::render_classid`. + #[must_use] + pub const fn render_classid(&self, app_prefix: u16) -> u32 { + ((self.concept_id() as u32) << 16) | (app_prefix as u32) + } +} + +/// The curated public structure atlas — the non-bone anatomical sites a +/// clinical reasoning layer localizes to. Extended as `/helix` pins more +/// canonical FMA ids; the name→partition→concept routing is stable regardless. +const ATLAS: &[FmaStructure] = &[ + // Organs — route on `anatomical_structure` (0x0A01). FMA ids filled where + // canonical + stable (same practice as the skeleton atlas's bone ids). + FmaStructure { + name: "kidney", + fma_id: Some(7203), + partition: FmaPartition::Organ, + laterality: Laterality::Paired, + }, + FmaStructure { + name: "lung", + fma_id: Some(7195), + partition: FmaPartition::Organ, + laterality: Laterality::Paired, + }, + FmaStructure { + name: "pancreas", + fma_id: Some(7198), + partition: FmaPartition::Organ, + laterality: Laterality::Unpaired, + }, + FmaStructure { + name: "thyroid gland", + fma_id: Some(9603), + partition: FmaPartition::Organ, + laterality: Laterality::Unpaired, + }, + // Joints — route on `joint` (0x0A04). + FmaStructure { + name: "synovial joint", + fma_id: Some(7501), + partition: FmaPartition::Joint, + laterality: Laterality::Bilateral, + }, + FmaStructure { + // The classic podagra site. Numeric FMA id is a loose end (many + // per-toe MTP-joint ids exist; not fabricating the aggregate here). + name: "first metatarsophalangeal joint", + fma_id: None, + partition: FmaPartition::Joint, + laterality: Laterality::Unpaired, + }, + // Systems / trees — route on `anatomical_structure` (0x0A01). + FmaStructure { + name: "systemic arterial system", + fma_id: None, + partition: FmaPartition::System, + laterality: Laterality::Unpaired, + }, + FmaStructure { + name: "bronchial tree", + fma_id: None, + partition: FmaPartition::System, + laterality: Laterality::Bilateral, + }, + // Tissue — routes on `anatomical_structure` (0x0A01). + FmaStructure { + name: "bone marrow", + fma_id: None, + partition: FmaPartition::Tissue, + laterality: Laterality::Unpaired, + }, +]; + +/// Resolve an anatomical-structure name to its public FMA reference row. +/// Case-insensitive on ASCII. `None` for a name not (yet) in the atlas — a +/// consumer treats that as a captured loose end, exactly as it would an +/// unfilled `fma_id`. +/// +/// ``` +/// let knee = ogar_fma::resolve("synovial joint").unwrap(); +/// assert_eq!(knee.concept_id(), ogar_vocab::class_ids::JOINT); +/// assert_eq!(knee.fma_id, Some(7501)); +/// // routes into the Anatomy domain, never Health/PHI: +/// assert_eq!( +/// ogar_vocab::canonical_concept_domain(knee.concept_id()), +/// ogar_vocab::ConceptDomain::Anatomy, +/// ); +/// ``` +#[must_use] +pub fn resolve(name: &str) -> Option<&'static FmaStructure> { + ATLAS + .iter() + .find(|s| s.name.eq_ignore_ascii_case(name.trim())) +} + +/// Every structure in the public atlas — for a consumer building a complete +/// name→classid index once at startup. +#[must_use] +pub fn atlas() -> &'static [FmaStructure] { + ATLAS +} + +#[cfg(test)] +mod tests { + use super::*; + use ogar_vocab::{ConceptDomain, canonical_concept_domain, class_ids}; + + #[test] + fn every_structure_routes_into_the_anatomy_domain() { + // The firewall split: an FMA structure is public Anatomy, never Health. + for s in atlas() { + assert_eq!( + canonical_concept_domain(s.concept_id()), + ConceptDomain::Anatomy, + "{} escaped the Anatomy domain", + s.name, + ); + } + } + + #[test] + fn joints_route_on_joint_organs_on_structure() { + assert_eq!( + resolve("synovial joint").unwrap().concept_id(), + class_ids::JOINT, + ); + assert_eq!( + resolve("first metatarsophalangeal joint") + .unwrap() + .concept_id(), + class_ids::JOINT, + ); + assert_eq!( + resolve("kidney").unwrap().concept_id(), + class_ids::ANATOMICAL_STRUCTURE, + ); + assert_eq!( + resolve("bronchial tree").unwrap().concept_id(), + class_ids::ANATOMICAL_STRUCTURE, + ); + } + + #[test] + fn resolve_is_case_and_whitespace_insensitive() { + assert_eq!(resolve(" KIDNEY ").unwrap().name, "kidney"); + assert!(resolve("Synovial Joint").is_some()); + assert!(resolve("no such structure").is_none()); + } + + #[test] + fn confident_fma_ids_are_filled_uncertain_are_loose_ends() { + // Filled where canonical + stable; None (honest loose end) otherwise. + assert_eq!(resolve("kidney").unwrap().fma_id, Some(7203)); + assert_eq!(resolve("synovial joint").unwrap().fma_id, Some(7501)); + assert_eq!(resolve("bone marrow").unwrap().fma_id, None); + assert_eq!(resolve("systemic arterial system").unwrap().fma_id, None,); + } + + #[test] + fn render_classid_packs_concept_high_appid_low() { + let s = resolve("synovial joint").unwrap(); + // q2 render skin 0x1000: hi-u16 = 0x0A04 (joint), lo-u16 = 0x1000. + assert_eq!(s.render_classid(0x1000), 0x0A04_1000); + assert_eq!((s.render_classid(0x1000) >> 16) as u16, class_ids::JOINT); + } + + #[test] + fn structure_names_are_unique() { + use std::collections::HashSet; + let mut seen = HashSet::new(); + for s in atlas() { + assert!(seen.insert(s.name), "duplicate structure {}", s.name); + } + } +}