File size: 22,562 Bytes
9a3640a
 
 
 
 
 
 
 
e3e0bee
 
9a3640a
e3e0bee
9a3640a
 
 
 
 
 
 
 
 
 
 
16ff626
9a3640a
c7415fe
9a3640a
 
16ff626
e3e0bee
16ff626
 
 
 
 
9a3640a
 
 
e3e0bee
9a3640a
 
 
 
 
 
 
 
16ff626
 
 
 
 
 
 
 
 
 
 
 
e3e0bee
 
 
9a3640a
 
 
 
 
 
16ff626
9a3640a
 
 
 
 
 
 
 
 
 
 
 
16ff626
9a3640a
 
 
 
 
 
 
16ff626
9a3640a
 
16ff626
9a3640a
 
 
c7415fe
 
 
 
 
9a3640a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16ff626
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a3640a
 
bc7e936
16ff626
 
 
 
bc7e936
16ff626
9a3640a
 
 
16ff626
 
 
9a3640a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c7415fe
9a3640a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16ff626
9a3640a
 
 
 
 
e3e0bee
9a3640a
 
 
 
 
 
e3e0bee
9a3640a
 
 
 
 
 
 
 
 
e3e0bee
 
 
 
9a3640a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f7b909f
 
9a3640a
e3e0bee
9a3640a
 
 
f7b909f
 
 
 
9a3640a
 
 
e3e0bee
f7b909f
 
 
 
 
 
 
 
9a3640a
 
 
 
e3e0bee
 
9a3640a
 
 
 
 
 
 
 
 
 
f7b909f
9a3640a
 
 
 
 
f7b909f
9a3640a
 
 
 
 
16ff626
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a3640a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16ff626
 
 
9a3640a
 
 
 
 
2351481
 
9a3640a
 
 
 
e3e0bee
 
9a3640a
 
 
 
 
2351481
 
 
 
9a3640a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65dff80
 
 
 
 
 
 
9a3640a
 
 
2351481
9a3640a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f7b909f
9a3640a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
"""Natural-language request to ranked CIPHER phenotypes.

Dense, two-surface retrieval over prebuilt Vidul BGE_FT_VA embeddings:

  - phenotype_metadata surface -> phenotype-level semantic similarity
  - code_evidence surface       -> code-level supporting evidence (max over chunks)

A phenotype is a document, not a bag of codes. Each candidate carries both channel
scores, a summary copied from source metadata, and the resolved code set. Ranking
uses the fine-tuned dense retriever.

Startup requires the matching embedding index and model.
"""

from __future__ import annotations

import json
import os
import re
from pathlib import Path

import numpy as np

from . import paths
from .retriever import DenseEmbedder
from .spelling import SpellSuggester

# The config beside the vectors records the query encoding convention.
EMB = paths.PARTA_EMB_DIR
# Optional detail sidecars.
CODE_DESC = paths.CODE_DESCRIPTIONS
ALGO_COMPONENTS = paths.ALGORITHM_COMPONENTS
FACETS = paths.PHENOTYPE_FACETS
SUMMARIES = paths.PHENOTYPE_SUMMARIES
BUNDLES = paths.RELATED_BUNDLES

# Algorithm-component fields to surface, in display order (professor 1.4:
# "refer to CIPHER algorithm components"). Only fields present for a phenotype
# are shown; absent fields are omitted.
_ALGO_FIELDS = [
    ("methods_used", "Methods"),
    ("related_diseases", "Related diseases"),
    ("data_period", "Data period"),
    ("validations", "Validation"),
    ("adjudication_method", "Adjudication"),
]


def _embedding_convention(config: dict) -> dict:
    """Return the build-time query contract; never invent missing defaults."""
    required = ("model", "pooling", "query_prefix", "max_seq_length")
    missing = [key for key in required if key not in config]
    if missing:
        raise RuntimeError(
            "Phenotype embedding config is missing its encoding convention: "
            + ", ".join(missing))
    return {key: config[key] for key in required}


def _method_label(value: str) -> str:
    return "Rule-based" if value.lower().startswith("rules-based") else value

# CIPHER public phenotype library. Detail page:
#   /web/cipher/phenotype-viewer?uqid={uqid}&name={slug}
# where slug = the full name with every non-alphanumeric char replaced by '_'.
# uqid comes from the cipher_links sidecar (present for ~79% of phenotypes);
# the rest fall back to the library root. Domain overridable for other deployments.
CIPHER_BASE = os.environ.get("ENCODE_CIPHER_BASE", "https://phenomics.va.ornl.gov").rstrip("/")
CIPHER_LINKS = paths.CIPHER_LINKS

# Friendly stand-ins so a still-unresolved code never shows the raw
# 'unsupported_code_system' token (professor feedback 1.1). (label, source)
_UNRESOLVED_NOTE = {
    466: ("Medication code", "needs_vocab:RxNorm/NDC"),
    467: ("LOINC lab code", "needs_vocab:LOINC"),
    471: ("OMOP concept ID", "needs_vocab:OMOP"),
    465: ("VA clinic stop code", "needs_vocab:VA_STOP"),
    468: ("Study-defined variable", "study_specific"),
    519: ("Study-defined variable", "study_specific"),
}


class EncodeEngine:
    def __init__(self, embedder: DenseEmbedder | None = None, emb_dir: Path | str | None = None):
        emb = Path(emb_dir) if emb_dir else EMB
        self.emb_dir = emb
        if not (emb / "config.json").exists():
            raise RuntimeError(f"Missing runtime phenotype embeddings: {emb}")
        self.config = json.loads((emb / "config.json").read_text())
        convention = _embedding_convention(self.config)

        self.phenotypes = {}
        for line in paths.CANONICAL_PHENOTYPES.open():
            r = json.loads(line)
            self.phenotypes[r["phenotype_id"]] = r

        # "Did you mean" vocabulary from the phenotype text itself.
        self._suggester = SpellSuggester(
            text for p in self.phenotypes.values()
            for text in (p.get("title"), p.get("description"), *(p.get("keywords") or [])))

        # Metadata surface: one row per phenotype, aligned to meta_ids.
        self.meta_ids: list[int] = json.loads((emb / "metadata_ids.json").read_text())
        self.meta_mat = np.load(emb / "metadata.npy").astype(np.float32)
        self._row = {pid: i for i, pid in enumerate(self.meta_ids)}

        # Code surface: many rows per phenotype; map each to its phenotype row.
        code_meta = json.loads((emb / "code_meta.json").read_text())
        self.code_mat = np.load(emb / "code.npy").astype(np.float32)
        self._code_row = np.array([self._row.get(m["phenotype_id"], -1) for m in code_meta])

        # Per-phenotype filters aligned to meta_ids.
        self._cat = np.array([self.phenotypes[p].get("category") or "(none)" for p in self.meta_ids])
        self._validated = np.array([bool(self.phenotypes[p].get("validated")) for p in self.meta_ids])

        # Part B: resolved code descriptions + algorithm components (optional sidecars).
        self.code_desc = {}
        if CODE_DESC.exists():
            for line in CODE_DESC.open():
                r = json.loads(line)
                self.code_desc[f"{r['code_type_id']}|{r['sub_type_id']}|{(r['code'] or '').upper()}"] = r
        self.cipher_uqid = {}
        if CIPHER_LINKS.exists():
            for line in CIPHER_LINKS.open():
                r = json.loads(line)
                self.cipher_uqid[r["phenotype_id"]] = r["uqid"]

        # Extracted CIPHER algorithm components (methods / related diseases /
        # validation / adjudication / data period); one record per phenotype.
        self.algo_components = {}
        if ALGO_COMPONENTS.exists():
            for line in ALGO_COMPONENTS.open():
                r = json.loads(line)
                self.algo_components[r["phenotype_id"]] = r

        # Offline facet extraction (evidence-or-abstain); enum values reach the
        # payload only when the source stated them, never inferred.
        self.facets = {}
        if FACETS.exists():
            for line in FACETS.open():
                r = json.loads(line)
                self.facets[r["phenotype_id"]] = r.get("facets") or {}

        # Optional LLM sidecars: grounded plain-language summaries and
        # related-concept bundles (search seeds, never codes).
        self.llm_summaries = {}
        if SUMMARIES.exists():
            for line in SUMMARIES.open():
                r = json.loads(line)
                self.llm_summaries[r["phenotype_id"]] = r
        self.related_bundles = {}
        if BUNDLES.exists():
            for line in BUNDLES.open():
                r = json.loads(line)
                self.related_bundles[r["phenotype_id"]] = r

        # Query embedder must match how the documents were encoded (model +
        # pooling + prefix + max-seq all travel in config.json).
        #
        # ENCODE_PARTA_MODEL repoints the query encoder for the default vector
        # set (the Space image uses it to load local weights instead of the
        # hub). It applies only to the default vectors because queries and
        # documents must use the same model.
        override = os.environ.get("ENCODE_PARTA_MODEL") if emb.resolve() == EMB.resolve() else None
        model_name = override or convention["model"]
        self.model_name = model_name
        self.embedder = embedder or DenseEmbedder(
            model_name,
            pooling=convention["pooling"],
            query_prefix=convention["query_prefix"],
            max_seq_length=convention["max_seq_length"])

    def categories(self) -> list[str]:
        return sorted(set(self._cat.tolist()))

    # -- retrieval ---------------------------------------------------------
    def search(self, query: str, k: int = 10, categories: set[str] | None = None,
               validated_only: bool = False) -> dict:
        q = self.embedder.encode([query])[0]
        meta_score = self.meta_mat @ q                     # (n_pheno,)

        # Max code-evidence similarity aggregated to phenotype level.
        code_sims = self.code_mat @ q                      # (n_code,)
        code_score = np.zeros(len(self.meta_ids), dtype=np.float32)
        valid = self._code_row >= 0
        np.maximum.at(code_score, self._code_row[valid], code_sims[valid])

        retrieval = 0.5 * meta_score + 0.5 * code_score    # bi-encoder candidate score

        mask = np.ones(len(self.meta_ids), dtype=bool)
        if categories:
            mask &= np.isin(self._cat, list(categories))
        if validated_only:
            mask &= self._validated

        candidates = np.flatnonzero(mask)
        results = []
        if len(candidates):
            candidate_scores = retrieval[candidates]
            for j in np.argsort(candidate_scores)[::-1][:k]:
                i = candidates[j]
                results.append(self._result(self.meta_ids[i], float(meta_score[i]),
                                            float(code_score[i]),
                                            float(candidate_scores[j])))
        return {
            "query": query,
            "model": self.model_name,
            "count": len(results),
            "suggestion": self._suggester.suggest(query.strip()),
            "results": results,
        }

    def _result(self, pid, m, c, relevance) -> dict:
        p = self.phenotypes[pid]
        evidence = [
            {"code_system": g.get("code_type_label"),
             "code_count": g.get("code_count") or len(g.get("codes", [])),
             "samples": [x.get("code") for x in g.get("codes", [])[:6]]}
            for g in p.get("associated_code_groups", [])
        ]
        return {
            "phenotype_id": pid,
            "title": p.get("title"),
            "category": p.get("category"),
            "validated": p.get("validated"),
            "summary": self._summary(p),
            "keywords": p.get("keywords") or [],
            "scores": {"relevance": round(relevance, 4),
                       "metadata": round(m, 4), "code_evidence": round(c, 4)},
            "code_systems": [e["code_system"] for e in evidence],
            "code_evidence": evidence,
            "facets": self._facet_payload(pid),
            "warnings": [],
        }

    @staticmethod
    def _summary(p: dict) -> str:
        """Return a shortened source description."""
        desc = (p.get("description") or p.get("algorithm_description") or "").strip()
        if len(desc) > 360:
            desc = desc[:360].rsplit(" ", 1)[0] + "…"
        return desc

    def _algo_component_rows(self, pid: int) -> list[dict]:
        """Format available algorithm components as ordered label/value rows."""
        rec = self.algo_components.get(pid)
        if not rec:
            return []
        rows = []
        for key, label in _ALGO_FIELDS:
            val = rec.get(key)
            if not val:
                continue
            if isinstance(val, list):
                values = [str(item) for item in val]
                if key == "methods_used":
                    values = [_method_label(item) for item in values]
                text = ", ".join(values)
            elif isinstance(val, dict):
                text = (f"{val.get('start', '?')} to {val.get('end', '?')}"
                        if ("start" in val or "end" in val)
                        else ", ".join(f"{k}: {v}" for k, v in val.items()))
            else:
                text = str(val)
            rows.append({"label": label, "value": text})
        return rows

    def _cipher_url(self, pid: int, title: str | None) -> str:
        uqid = self.cipher_uqid.get(pid)
        if uqid:
            slug = re.sub(r"[^A-Za-z0-9]", "_", title or "")
            return f"{CIPHER_BASE}/web/cipher/phenotype-viewer?uqid={uqid}&name={slug}"
        return CIPHER_BASE + "/"

    def _code_display(self, code_type_id, sub_type_id,
                      entry: dict) -> tuple[str | None, str, dict]:
        """Resolve a code to (description, source), replacing 'unsupported_code_system'
        when a description exists in the sidecar."""
        status = entry.get("label_status")
        labels = entry.get("labels") or []
        if status in ("exact", "prefix_expanded") and labels:
            return labels[0], status, {}
        # Both gaps are looked up: a code whose system we had no dictionary for,
        # and a code whose system we support but whose label our tables lacked.
        if status in ("unsupported_code_system", "label_missing"):
            hit = self.code_desc.get(
                f"{code_type_id}|{sub_type_id}|{(entry.get('code') or '').upper()}")
            if hit:
                # Return the recorded match method and release with the label.
                extra = {k: hit[k] for k in ("source_version", "match", "concept")
                         if hit.get(k)}
                return hit["description"], hit["source"], extra
            if status == "unsupported_code_system":
                note = _UNRESOLVED_NOTE.get(code_type_id,
                                            ("No standard description", "unresolved"))
                return note[0], note[1], {}
        return (labels[0] if labels else None), status, {}

    # -- detail ------------------------------------------------------------
    def _code_graphable(self, ct: int | None, code: str | None) -> bool:
        """Does this specific code have a knowledge-graph? ICD diagnosis (460/461)
        resolves to an ICD family; medication terms (466) use a local map or an
        exact/normalized RxNAV lookup."""
        if ct in (460, 461):
            return True
        if ct == 466:
            return bool((code or "").strip())
        return False

    def _code_group_detail(self, g: dict) -> dict:
        ct, sub = g.get("code_type_id"), g.get("sub_type_id")
        codes, resolved = [], 0
        for x in g.get("codes", [])[:200]:
            desc, source, extra = self._code_display(ct, sub, x)
            if desc and source not in ("study_specific", "unresolved", "label_missing") \
                    and not source.startswith("needs_vocab"):
                resolved += 1
            codes.append({"code": x.get("code"), "description": desc,
                          "description_source": source, "label_status": x.get("label_status"),
                          "graphable": self._code_graphable(ct, x.get("code")), **extra})
        return {"code_system": g.get("code_type_label"), "sub_type": g.get("sub_type_label"),
                "code_count": g.get("code_count") or len(g.get("codes", [])),
                "resolved_count": resolved, "codes": codes,
                "graphable": any(c["graphable"] for c in codes)}

    def _facet_payload(self, pid: int) -> dict | None:
        """Compact facet view for the UI: stated values only, criteria as text."""
        f = self.facets.get(pid)
        if not f:
            return None

        def stated(name):
            v = ((f.get(name) or {}).get("value") or "").strip()
            return v if v and v != "not_stated" else None

        out = {
            "age_group": stated("age_group"),
            "care_setting": stated("care_setting"),
            "incident_vs_prevalent": stated("incident_vs_prevalent"),
            "intended_use": stated("intended_use"),
            "inclusion": [c.get("criterion") for c in f.get("inclusion") or [] if c.get("criterion")],
            "exclusion": [c.get("criterion") for c in f.get("exclusion") or [] if c.get("criterion")],
        }
        return out if any(out.values()) else None

    def phenotype(self, pid: int) -> dict | None:
        p = self.phenotypes.get(pid)
        if not p:
            return None
        return {
            "phenotype_id": pid,
            "algorithm_id": p.get("algorithm_id"),
            "title": p.get("title"),
            "category": p.get("category"),
            "validated": p.get("validated"),
            "validation_description": p.get("validation_description"),
            "description": p.get("description"),
            "algorithm_description": p.get("algorithm_description"),
            "keywords": p.get("keywords") or [],
            "authors": p.get("authors") or [],
            "publications": p.get("publications") or [],
            "population_description": p.get("population_description"),
            "last_modified": p.get("last_modified"),
            "cipher_url": self._cipher_url(pid, p.get("title")),
            "algorithm_components": self._algo_component_rows(pid),
            "facets": self._facet_payload(pid),
            "llm_summary": self.llm_summaries.get(pid),
            "related_bundle": self.related_bundles.get(pid),
            "code_groups": [self._code_group_detail(g)
                            for g in p.get("associated_code_groups", [])],
        }

    # -- phenotype code hierarchy (phecode -> ICD main -> ICD sub) ----------
    def phenotype_code_graph(self, pid: int, focus: str | None = None,
                             cap: int | None = None) -> dict | None:
        """A node+edge tree of the phenotype's diagnosis codes: the phenotype (as
        the phecode-level concept) -> each ICD main category (3-char stem) -> the
        specific ICD sub-codes under it (460 ICD-9, 461 ICD-10).

        When a code is clicked (`focus`), only its main branch is expanded and
        sub-codes are windowed around it. Other branches collapse to a count.
        Overflow within a branch is shown as a single "+N more" stub (an edge to a
        count, not every node). `path` = the root→main→code chain to highlight."""
        p = self.phenotypes.get(pid)
        if not p:
            return None
        # cap=0 disables every window: all categories expand with all codes.
        unlimited = cap == 0
        BIG = 10 ** 9
        FOCUS_SUBS, MAIN_SUBS, MAX_MAINS = (BIG, BIG, BIG) if unlimited else (14, 6, 20)
        focus = (focus or "").strip() or None
        focus_main = focus.split(".")[0] if focus else None
        root = "pheno"
        title = p.get("title") or f"Phenotype {pid}"
        nodes = [{"id": root, "label": title[:60], "sub": "CIPHER phenotype (phecode)",
                  "tier": 0, "current": focus is None, "path": True}]
        edges, path = [], [root]
        note = None

        for g in p.get("associated_code_groups", []):
            ct = g.get("code_type_id")
            if ct not in (460, 461):
                continue
            system = "ICD-9" if ct == 460 else "ICD-10"
            sub_type = g.get("sub_type_id")
            by_main: dict[str, list[dict]] = {}
            for x in g.get("codes", []):
                code = (x.get("code") or "").strip()
                if code:
                    by_main.setdefault(code.split(".")[0], []).append(x)
            # focus can be a leaf code (clicked in the drawer) or a main category
            # (clicked to expand it) — both expand that main; a leaf also highlights.
            group_has_focus = focus is not None and focus_main in by_main
            focus_is_leaf = group_has_focus and any(x.get("code") == focus for x in by_main[focus_main])

            mains = sorted(by_main)
            sel_mains = mains[:MAX_MAINS]
            if group_has_focus and focus_main not in sel_mains:
                sel_mains = sel_mains[:MAX_MAINS - 1] + [focus_main]
            if len(mains) > len(sel_mains):
                note = "Some categories are truncated."

            for cat in sel_mains:
                # Namespaced, because a category and one of its codes can be the
                # same string: a phenotype whose code list holds the 3-character
                # stem "F32" produced a category node and a code node with the
                # id "ICD-10:F32", and the two drew on top of each other in the
                # same column. The category rung is its own kind of thing and
                # gets its own id space.
                main_id = f"cat:{system}:{cat}"
                subs = by_main[cat]
                is_focus_main = group_has_focus and cat == focus_main
                # When focused, only the focused branch expands; others collapse.
                collapse = focus is not None and not is_focus_main and not unlimited
                nodes.append({"id": main_id, "label": cat, "tier": 1, "current": False,
                              "sub": (f"{system} · {len(subs)} code" + ("s" if len(subs) != 1 else "")
                                      if collapse else f"{system} category"),
                              "path": is_focus_main, "nav": {"code": cat, "code_type": system}})
                edges.append([root, main_id])
                if is_focus_main:
                    path.append(main_id)
                if collapse:
                    continue

                cap = FOCUS_SUBS if is_focus_main else MAIN_SUBS
                if is_focus_main and focus_is_leaf and len(subs) > cap:   # window around the focus
                    idx = next(i for i, x in enumerate(subs) if x.get("code") == focus)
                    start = max(0, min(idx - cap // 2, len(subs) - cap))
                    sel = subs[start:start + cap]
                else:
                    sel = subs[:cap]
                for x in sel:
                    desc, _, _extra = self._code_display(ct, sub_type, x)
                    code = x["code"]
                    sid = f"{system}:{code}"
                    is_cur = code == focus
                    nodes.append({"id": sid, "label": code, "sub": desc or "", "tier": 2,
                                  "current": is_cur, "path": is_cur,
                                  "nav": {"code": code, "code_type": system}})
                    edges.append([main_id, sid])
                    if is_cur:
                        path.append(sid)
                if len(subs) > len(sel):                       # overflow stub, not every node
                    more_id = f"more:{main_id}"
                    nodes.append({"id": more_id, "label": f"+{len(subs) - len(sel)} more",
                                  "sub": "", "tier": 2, "more": True})
                    edges.append([main_id, more_id])

        if len(nodes) == 1:
            return {"available": False, "kind": "phenotype", "code": str(pid),
                    "reason": "This phenotype has no ICD diagnosis codes to chart."}
        return {"available": True, "kind": "phenotype", "code": str(pid), "title": title,
                "subtitle": "Phecode → ICD main categories → ICD sub-codes"
                            + (" (click a category to expand it)" if focus else ""),
                "nodes": nodes, "edges": edges, "path": path, "note": note}