Skip to main content

qualia_client_core/wellfair/
anatomy_pack.rs

1//! Produce a shippable **curated `.hmc` anatomy asset pack** for a model.
2//!
3//! The full CCF/HRA reference body is ~200–290 MB of GLB per model — too large to
4//! bundle into a release. This builds a *curated* subset (a representative set of
5//! organs across body systems, tens of MB) into a single `.hmc` bundle (see
6//! [`qualia_core_db::bundle`]): each organ is a sealed `.10d` entry carrying an
7//! [`AnatomyOrganMeta`] (system + approximate position + neutral colour). The
8//! bundle is the artefact shipped in the desktop release resources and published
9//! for the web demo, so a fresh install / the online demo renders a real body
10//! with no per-user download.
11//!
12//! This is a **producer** (used by the `build_anatomy_pack` example / CI), not a
13//! runtime path — it does blocking network I/O against the HRA endpoints.
14
15#![cfg(not(target_arch = "wasm32"))]
16
17use std::collections::HashMap;
18use std::path::Path;
19
20use serde::Serialize;
21use wellfare_core::anatomy::{normalize_organ_key, system_memberships_for_organ, AnatomyModel};
22
23use qualia_core_db::bundle::BundleWriter;
24use qualia_core_db::q42_volume::UnifiedVolumeBuilder;
25use qualia_core_db::render::anatomy_pack::AnatomyOrganMeta;
26use qualia_core_db::{NQuin, QUINS_PER_BLOCK};
27
28use super::anatomy_body::{body_container, compile_body, organ_container, CompiledOrgan};
29use super::ccf_resolver::{discover_ref_organs, fetch_glb, organs_for_model, HRA_SPARQL_ENDPOINT};
30
31/// The default curated organ set — normalised base tokens (laterality/sex/`.glb`
32/// stripped). A representative spread across body systems, kept small so the pack
33/// is tens of MB. Discovery reports which of these were actually found.
34pub const CURATED_ORGAN_TOKENS: &[&str] = &[
35    // nervous
36    "brain",
37    "spinal-cord",
38    // circulatory
39    "heart",
40    // respiratory
41    "lung",
42    "trachea",
43    "larynx",
44    "main-bronchus",
45    // digestive
46    "liver",
47    "pancreas",
48    "small-intestine",
49    "large-intestine",
50    "mouth",
51    // urinary
52    "kidney",
53    "urinary-bladder",
54    "ureter",
55    // immune / lymphatic
56    "spleen",
57    "thymus",
58    "lymph-node",
59    // integumentary (skin — the outer body surface; the mixer defaults it muted so it
60    // doesn't occlude the organs, and peels it on demand)
61    "skin",
62    // sensory
63    "eye",
64    // skeletal
65    "pelvis",
66    // reproductive (model-specific — each reference body matches only its own organs)
67    "prostate",
68    "uterus",
69    "ovary",
70    "vagina",
71    "fallopian-tube",
72];
73
74/// A discovered organ: its CCF filename and its normalised base token.
75#[derive(Debug, Clone, Serialize)]
76pub struct DiscoveredOrgan {
77    pub filename: String,
78    pub token: String,
79}
80
81/// Discover every reference organ for a model (filename + normalised token).
82/// Useful for curating [`CURATED_ORGAN_TOKENS`] against what the HRA actually
83/// serves.
84pub fn discover_model_organs(model: AnatomyModel) -> Result<Vec<DiscoveredOrgan>, String> {
85    let all =
86        discover_ref_organs(HRA_SPARQL_ENDPOINT).map_err(|e| format!("SPARQL discovery: {e}"))?;
87    Ok(organs_for_model(&all, model)
88        .into_iter()
89        .map(|o| DiscoveredOrgan {
90            token: normalize_organ_key(&o.filename),
91            filename: o.filename,
92        })
93        .collect())
94}
95
96/// The result of building a pack — honest about what packed and what curated
97/// organ was not found/failed.
98#[derive(Debug, Clone, Serialize)]
99pub struct PackReport {
100    pub model: String,
101    pub out_path: String,
102    pub organs_packed: usize,
103    pub total_10d_bytes: usize,
104    pub bundle_bytes: usize,
105    /// Curated tokens that were requested but not discovered for this model.
106    pub curated_not_found: Vec<String>,
107    /// (filename, error) for organs that failed to fetch or compile.
108    pub failed: Vec<(String, String)>,
109    /// Organ keys packed, in order.
110    pub packed_keys: Vec<String>,
111    /// Size in bytes of the pack-level `.q42` provenance/semantics graph (carried in the
112    /// bundle as `body.q42` and written beside the bundle as a linkable sidecar).
113    pub q42_graph_bytes: usize,
114    /// Number of quins (facts) in the pack `.q42` graph.
115    pub q42_quins: usize,
116    /// Path the `.q42` sidecar was written to.
117    pub q42_sidecar_path: String,
118}
119
120/// Build a curated `.hmc` pack for `model` and write it to `out_path`.
121///
122/// `curated` is the set of normalised base tokens to include (defaults to
123/// [`CURATED_ORGAN_TOKENS`] when `None`). Blocking network I/O.
124pub fn build_anatomy_pack(
125    model: AnatomyModel,
126    out_path: impl AsRef<Path>,
127    curated: Option<&[&str]>,
128) -> Result<PackReport, String> {
129    let out_path = out_path.as_ref();
130
131    // Discover every reference organ for this model.
132    let all =
133        discover_ref_organs(HRA_SPARQL_ENDPOINT).map_err(|e| format!("SPARQL discovery: {e}"))?;
134    let model_organs = organs_for_model(&all, model);
135
136    // `Some(list)` selects a curated subset by normalised token; `None` builds the COMPLETE body —
137    // every discovered reference organ for the model (skin, vasculature, and all). Since the renderer
138    // now places organs by their true shared-space coordinates, no per-organ position curation is needed.
139    let (selected, curated_not_found): (Vec<_>, Vec<String>) = match curated {
140        Some(list) => {
141            let sel: Vec<_> = model_organs
142                .iter()
143                .filter(|o| {
144                    let key = normalize_organ_key(&o.filename);
145                    list.iter().any(|t| key.as_str() == *t)
146                })
147                .cloned()
148                .collect();
149            let found: std::collections::BTreeSet<String> = sel
150                .iter()
151                .map(|o| normalize_organ_key(&o.filename))
152                .collect();
153            let missing: Vec<String> = list
154                .iter()
155                .filter(|t| !found.contains(**t))
156                .map(|t| (*t).to_string())
157                .collect();
158            (sel, missing)
159        }
160        None => (model_organs.clone(), Vec::new()),
161    };
162
163    if selected.is_empty() {
164        return Err(format!(
165            "no reference organs discovered for {}",
166            model.as_str()
167        ));
168    }
169
170    // Fetch each selected GLB.
171    let mut fetched: Vec<(String, Vec<u8>)> = Vec::new();
172    let mut failed: Vec<(String, String)> = Vec::new();
173    for organ in &selected {
174        match fetch_glb(&organ.glb_url) {
175            Ok(bytes) => fetched.push((organ.filename.clone(), bytes)),
176            Err(e) => failed.push((organ.filename.clone(), format!("fetch: {e}"))),
177        }
178    }
179
180    // Compile the fetched GLBs to sealed `.10d`.
181    let compiled = compile_body(model, &fetched);
182    for (k, e) in &compiled.failed {
183        failed.push((k.clone(), format!("compile: {e}")));
184    }
185
186    // Pack each compiled organ as a `.10d` entry with its render meta.
187    let mut writer = BundleWriter::new();
188    let mut total_10d_bytes = 0usize;
189    let mut packed_keys: Vec<String> = Vec::new();
190    for organ in &compiled.organs {
191        // All systems this organ participates in (primary first) — so the pack supports colouring by the
192        // primary system OR blending across memberships, and a condition on any member system lights it.
193        let systems: Vec<String> = system_memberships_for_organ(&organ.organ_key)
194            .into_iter()
195            .map(|(s, _)| s.to_string())
196            .collect();
197        let meta = AnatomyOrganMeta {
198            system: organ.system_id.clone(),
199            label: normalize_organ_key(&organ.organ_key), // "3d-vh-m-heart.glb" → "heart"
200            systems,
201            position: position_for(&organ.organ_key),
202            rgba: palette_for(&organ.system_id),
203        };
204        let bytes = organ.asset.container_10d.clone();
205        total_10d_bytes += bytes.len();
206        writer
207            .add_file(organ.organ_key.clone(), "10d", bytes, Some(meta.to_cbor()))
208            .map_err(|e| format!("bundle add {}: {e}", organ.organ_key))?;
209        packed_keys.push(organ.organ_key.clone());
210    }
211
212    // Pack-level `.q42`: the body's provenance + organ→system semantic graph. It is the
213    // growing semantic spine the copyright panel links to, and to which disease↔organ links
214    // and — privately, client-side — the person's own conditions are later appended. Built
215    // from the SAME hypermedia containers the desktop uses, so the pack's semantics are the
216    // product's semantics (not a demo aside). Its source citations use the real CCF/HRA CDN
217    // URLs each GLB was fetched from.
218    let source_urls: HashMap<String, String> = selected
219        .iter()
220        .map(|o| (o.filename.clone(), o.glb_url.clone()))
221        .collect();
222    let (q42_bytes, q42_quins) = build_pack_q42(model, &compiled.organs, &source_urls);
223    let q42_graph_bytes = q42_bytes.len();
224    // Carried INSIDE the bundle (one attestable unit alongside the `.10d` meshes).
225    writer
226        .add_file("body.q42", "q42", q42_bytes.clone(), None)
227        .map_err(|e| format!("bundle add body.q42: {e}"))?;
228
229    let bundle = writer.build().map_err(|e| format!("bundle build: {e}"))?;
230    if let Some(parent) = out_path.parent() {
231        std::fs::create_dir_all(parent).map_err(|e| format!("create out dir: {e}"))?;
232    }
233    std::fs::write(out_path, &bundle).map_err(|e| format!("write {}: {e}", out_path.display()))?;
234
235    // Also write the same `.q42` as a standalone sidecar next to the bundle (e.g.
236    // `anatomy-male.q42`) — a directly-linkable provenance/semantics file for the web demo,
237    // byte-identical to the bundle's `body.q42` entry (one graph, two carriers, no drift).
238    let q42_sidecar = out_path.with_extension("q42");
239    std::fs::write(&q42_sidecar, &q42_bytes)
240        .map_err(|e| format!("write {}: {e}", q42_sidecar.display()))?;
241
242    Ok(PackReport {
243        model: model.as_str().to_string(),
244        out_path: out_path.display().to_string(),
245        organs_packed: compiled.organs.len(),
246        total_10d_bytes,
247        bundle_bytes: bundle.len(),
248        curated_not_found,
249        failed,
250        packed_keys,
251        q42_graph_bytes,
252        q42_quins,
253        q42_sidecar_path: q42_sidecar.display().to_string(),
254    })
255}
256
257/// Aggregate the compiled organs into a single pack-level `.q42` graph volume: each organ
258/// becomes a hypermedia container (its sealed `.10d` ⊕ its CCF/HRA source with licence +
259/// creator ⊕ topic/system/depiction descriptors), bundled into one **body** container. The
260/// returned bytes are a valid unified v3 `.q42` (object-sorted blocks) carrying the full
261/// provenance + organ→system semantics — the same graph the desktop builds. Returns the
262/// bytes and the number of quins (facts) in the graph.
263fn build_pack_q42(
264    model: AnatomyModel,
265    organs: &[CompiledOrgan],
266    source_urls: &HashMap<String, String>,
267) -> (Vec<u8>, usize) {
268    let containers: Vec<_> = organs
269        .iter()
270        .map(|o| organ_container(o, model, source_urls.get(&o.organ_key).map(String::as_str)))
271        .collect();
272    let body = body_container(model, &containers);
273    let quins = body.quins.len();
274    (q42_bytes_from_graph(&body.quins, &body.lexicon), quins)
275}
276
277/// Serialise a quin graph + object-lexicon into unified v3 `.q42` bytes. Quins are sorted by
278/// `object` and chunked into [`QUINS_PER_BLOCK`]-sized SuperBlocks so the volume's BIDX (which
279/// the header advertises as object-sorted) is truthful and object-hash lookups resolve.
280fn q42_bytes_from_graph(quins: &[NQuin], lexicon: &HashMap<u64, String>) -> Vec<u8> {
281    let mut sorted = quins.to_vec();
282    sorted.sort_by_key(|q| q.object);
283    let mut builder = UnifiedVolumeBuilder::with_lex_map(lexicon)
284        .expect("body Q42 lexicon entries fit the current Q42LEX format");
285    for (seq, chunk) in sorted.chunks(QUINS_PER_BLOCK).enumerate() {
286        builder
287            .push_block(seq as u64, chunk)
288            .expect("body Q42 graph is object-sorted");
289    }
290    builder.finish_to_bytes()
291}
292
293/// The shipped default linear RGBA for a body system (the person's σ-derived burden colour overrides it
294/// at runtime). Delegates to the [`wellfare_core::anatomy`] **system registry** — the single source of
295/// truth for the system palette — so a registered extension system carries its own colour into the pack
296/// and no colour table drifts. Unknown/unregistered systems get the neutral swatch.
297fn palette_for(system: &str) -> [f32; 4] {
298    wellfare_core::anatomy::default_registry().color_of(system)
299}
300
301/// An approximate anatomical position `[x, y, z]` in 0..1 body space (x=right,
302/// y=up, z=front) for assembling the whole body. Approximate placement — a
303/// future pass can use real CCF spatial-placement transforms. Laterality
304/// (`-l`/`-r`) nudges x so paired organs don't overlap.
305fn position_for(filename: &str) -> [f32; 3] {
306    let token = normalize_organ_key(filename);
307    let [x, y, z] = match token.as_str() {
308        "brain" => [0.50, 0.93, 0.50],
309        "spinal-cord" => [0.50, 0.70, 0.44],
310        "trachea" => [0.50, 0.74, 0.55],
311        "thymus" => [0.50, 0.66, 0.55],
312        "heart" => [0.50, 0.60, 0.55],
313        "lung" => [0.42, 0.62, 0.50],
314        "liver" => [0.57, 0.53, 0.52],
315        "stomach" => [0.44, 0.52, 0.52],
316        "spleen" => [0.60, 0.52, 0.44],
317        "pancreas" => [0.50, 0.50, 0.46],
318        "gallbladder" => [0.56, 0.51, 0.55],
319        "kidney" => [0.50, 0.47, 0.40],
320        "small-intestine" => [0.50, 0.42, 0.55],
321        "large-intestine" => [0.50, 0.42, 0.60],
322        "urinary-bladder" => [0.50, 0.33, 0.55],
323        "larynx" => [0.50, 0.77, 0.55],
324        "main-bronchus" => [0.50, 0.66, 0.50],
325        "mouth" => [0.50, 0.85, 0.56],
326        "ureter" => [0.50, 0.40, 0.42],
327        "lymph-node" => [0.44, 0.68, 0.50],
328        "eye" => [0.50, 0.90, 0.57],
329        "pelvis" => [0.50, 0.35, 0.50],
330        "skin" => [0.50, 0.50, 0.50],
331        "prostate" => [0.50, 0.31, 0.50],
332        "uterus" => [0.50, 0.34, 0.50],
333        "ovary" => [0.50, 0.37, 0.45],
334        "vagina" => [0.50, 0.29, 0.50],
335        "fallopian-tube" => [0.50, 0.38, 0.45],
336        _ => [0.50, 0.50, 0.50],
337    };
338    // Laterality nudge for paired organs (kidney-l/-r, lung-l/-r, …).
339    let lower = filename.to_ascii_lowercase();
340    let x = if lower.contains("-l.") || lower.contains("-left") {
341        x - 0.09
342    } else if lower.contains("-r.") || lower.contains("-right") {
343        x + 0.09
344    } else {
345        x
346    };
347    [x, y, z]
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    /// The pack-level `.q42` must carry the real provenance (licence + source citation) and the
355    /// organ→system semantics — and open as a valid unified volume whose facts are queryable.
356    /// This is the growing semantic spine the copyright panel links to.
357    #[test]
358    fn pack_q42_carries_provenance_and_system_semantics() {
359        use qualia_core_db::q42_volume::{Q42Volume, Q42_MAGIC};
360
361        // Compile two organs whose systems are covered by the map (real GLB parsing is proven in
362        // render::assets; a stand-in OBJ triangle exercises the pack graph, not the GLB decoder).
363        const TRI_OBJ: &[u8] = b"v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n";
364        let organs = vec![
365            ("3d-vh-m-lung.obj".to_string(), TRI_OBJ.to_vec()),
366            (
367                "3d-vh-m-blood-vasculature.obj".to_string(),
368                TRI_OBJ.to_vec(),
369            ),
370        ];
371        let body = compile_body(AnatomyModel::Male, &organs);
372        assert_eq!(body.organs.len(), 2, "both organs mapped to a system");
373
374        let mut urls = HashMap::new();
375        urls.insert(
376            "3d-vh-m-lung.obj".to_string(),
377            "https://cdn.humanatlas.io/hra/lung.glb".to_string(),
378        );
379        let (q42, quin_count) = build_pack_q42(AnatomyModel::Male, &body.organs, &urls);
380        assert!(quin_count > 0, "the graph has facts");
381        assert!(q42.starts_with(&Q42_MAGIC), "produced a Q42 volume");
382
383        // It opens as a valid unified volume and every fact round-trips.
384        let tmp = tempfile::NamedTempFile::new().unwrap();
385        std::fs::write(tmp.path(), &q42).unwrap();
386        let vol = Q42Volume::open(tmp.path()).unwrap();
387        let quins = vol.read_all_quins().unwrap();
388        assert_eq!(
389            quins.len(),
390            quin_count,
391            "every fact recoverable from the .q42"
392        );
393
394        // Resolve the string-valued objects through the embedded lexicon.
395        let lex = vol.lex_view().unwrap();
396        let vals: Vec<String> = quins
397            .iter()
398            .filter_map(|q| lex.lookup_hash(q.object).map(str::to_string))
399            .collect();
400        // Provenance: the licence and the real CDN source URL are in the graph.
401        assert!(
402            vals.iter().any(|v| v == "CC-BY-4.0"),
403            "licence fact present: {vals:?}"
404        );
405        assert!(
406            vals.iter().any(|v| v.contains("humanatlas.io")),
407            "source citation present: {vals:?}"
408        );
409        // Organ→system: lung→respiratory and blood-vasculature→circulatory are both bound.
410        assert!(
411            vals.iter().any(|v| v == "respiratory"),
412            "lung system present"
413        );
414        assert!(
415            vals.iter().any(|v| v == "circulatory"),
416            "vasculature system present"
417        );
418    }
419
420    #[test]
421    fn laterality_nudges_paired_organs_apart() {
422        let l = position_for("3d-vh-f-kidney-l.glb");
423        let r = position_for("3d-vh-f-kidney-r.glb");
424        assert!(
425            l[0] < r[0],
426            "left kidney is left of right kidney: {l:?} {r:?}"
427        );
428        // Unpaired organ is centred.
429        assert_eq!(position_for("3d-vh-m-heart.glb")[0], 0.50);
430    }
431
432    #[test]
433    fn palette_covers_systems_with_neutral_fallback() {
434        // Canonical ids from wellfare_core::anatomy::systems get real colours…
435        assert_eq!(palette_for("circulatory")[0], 0.80);
436        assert_eq!(palette_for("immune_lymphatic")[1], 0.82);
437        // …and every organ we actually pack resolves to a non-default colour.
438        for sys in [
439            "nervous",
440            "circulatory",
441            "respiratory",
442            "digestive",
443            "urinary",
444            "immune_lymphatic",
445        ] {
446            assert_ne!(
447                palette_for(sys),
448                [0.62, 0.66, 0.72, 1.0],
449                "{sys} should have a colour"
450            );
451        }
452        // Unknown system falls back to neutral.
453        assert_eq!(palette_for("unknown-system"), [0.62, 0.66, 0.72, 1.0]);
454    }
455}