gem-mapping-studio 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env python3
2
+ """Build the GEM -> UMLS crosswalk by resolving each dimension/value to a
3
+ UMLS concept via the UTS REST API.
4
+
5
+ This is the harness. It reads the curated inventory
6
+ (``mapping/dimensions_inventory.yaml``), queries UMLS for each entry through a
7
+ tiered search (exact -> normalized -> words), assigns an honest status
8
+ (``mapped`` / ``review`` / ``unmapped`` / ``pending``), and writes a
9
+ machine-readable crosswalk to ``mapping/umls_crosswalk.yaml`` plus a coverage
10
+ summary. The LaTeX table is rendered separately by
11
+ ``mapping/render_crosswalk_tex.py`` from that YAML.
12
+
13
+ Honesty by construction:
14
+ * Nothing is mapped without a UMLS query actually returning a concept.
15
+ * Without an API key the harness runs but every entry is ``pending`` (queried
16
+ nothing); it does not invent CUIs.
17
+ * A query that finds candidates but no faithful exact/normalized match is
18
+ ``review`` (candidates recorded for a curator), never silently ``mapped``.
19
+
20
+ Usage:
21
+ # 1. verify the inventory still covers the SHACL enumerations
22
+ python3 mapping/build_umls_crosswalk.py --check-inventory
23
+
24
+ # 2. real run (needs a UMLS license; key in UMLS_API_KEY or --api-key)
25
+ UMLS_API_KEY=... python3 mapping/build_umls_crosswalk.py
26
+
27
+ # 3. offline run with no key -> all entries 'pending' (scaffold mode)
28
+ python3 mapping/build_umls_crosswalk.py --no-key
29
+
30
+ # 4. offline run against test fixtures (used by the unit tests)
31
+ python3 mapping/build_umls_crosswalk.py --fixtures mapping/fixtures \
32
+ --out mapping/umls_crosswalk.demo.yaml
33
+
34
+ Requirements: ``pyyaml``; ``requests`` for live runs; ``rdflib`` for
35
+ ``--check-inventory``.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ import os
41
+ import re
42
+ import unicodedata
43
+ from pathlib import Path
44
+
45
+ import yaml
46
+
47
+ from forome.gem.umls import semantic_types as stylib
48
+ from forome.gem.umls._paths import DATA_DIR, SCHEMA_DIR
49
+
50
+ BASE = SCHEMA_DIR.parent
51
+ INVENTORY = DATA_DIR / "dimensions_inventory.yaml"
52
+ ADJUDICATIONS = DATA_DIR / "adjudications.yaml"
53
+ SHACL = SCHEMA_DIR / "genetic_evidence.shacl.ttl"
54
+ DEFAULT_OUT = DATA_DIR / "umls_crosswalk.yaml"
55
+ CACHE_DIR = DATA_DIR / "cache"
56
+
57
+ # SHACL enum members deliberately NOT expected in the inventory:
58
+ # - the sentinel, declared a member of every dimension class
59
+ # - the legacy method synonyms (migrate to the renamed values)
60
+ ENUM_EXCLUDE = {
61
+ "NOT_APPLICABLE_OR_OMITTED",
62
+ "IN_VIVO_EXPERIMENT", "IN_VITRO_EXPERIMENT", "BIOINFORMATICS_PREDICTION",
63
+ }
64
+
65
+
66
+ def _norm(s: str | None) -> str:
67
+ if not s:
68
+ return ""
69
+ s = s.lower().strip()
70
+ # Fold accents to their base letters (NFD decomposition + drop combining
71
+ # marks) before stripping non-ASCII, so e.g. "Über" -> "uber", not "ber".
72
+ s = unicodedata.normalize("NFD", s)
73
+ s = "".join(c for c in s if unicodedata.category(c) != "Mn")
74
+ s = re.sub(r"[^a-z0-9 ]+", " ", s)
75
+ return re.sub(r"\s+", " ", s).strip()
76
+
77
+
78
+ # --------------------------------------------------------------------------
79
+ # Inventory loading: flatten the nested YAML into a list of entries.
80
+ # --------------------------------------------------------------------------
81
+
82
+ def load_entries(inventory_path: Path = INVENTORY) -> list[dict]:
83
+ doc = yaml.safe_load(inventory_path.read_text())
84
+ entries: list[dict] = []
85
+ for dim, body in (doc.get("dimensions") or {}).items():
86
+ axis = body.get("axis")
87
+ if axis:
88
+ entries.append(_entry(dim, None, "axis", axis))
89
+ for v in body.get("values") or []:
90
+ entries.append(_entry(dim, v["token"], "value", v))
91
+ for v in body.get("common_values") or []:
92
+ entries.append(_entry(dim, v["token"], "common_value", v))
93
+ return entries
94
+
95
+
96
+ def _entry(dim: str, token, kind: str, src: dict) -> dict:
97
+ return {
98
+ "dimension": dim,
99
+ "token": token,
100
+ "kind": kind,
101
+ "query": src["query"],
102
+ "expect": src.get("expect", "uncertain"),
103
+ "sab": src.get("sab"),
104
+ "inventory_note": src.get("note"),
105
+ }
106
+
107
+
108
+ # --------------------------------------------------------------------------
109
+ # Resolution: tiered UMLS search with honest status assignment.
110
+ # --------------------------------------------------------------------------
111
+
112
+ def resolve(client, query: str, sab: str | None, live: bool,
113
+ stys: str | None = None) -> dict:
114
+ if not live:
115
+ return {"status": "pending"}
116
+
117
+ def tier(use_sab):
118
+ s = sab if use_sab else None
119
+ for st in ("exact", "normalizedString", "words"):
120
+ cands = client.search(query, search_type=st, sabs=s, semantic_types=stys)
121
+ if cands:
122
+ return st, cands
123
+ return None, []
124
+
125
+ used_sab = bool(sab)
126
+ st, cands = tier(use_sab=True)
127
+ if not cands and sab:
128
+ st, cands = tier(use_sab=False)
129
+ used_sab = False
130
+
131
+ if not cands:
132
+ return {"status": "unmapped"}
133
+
134
+ top = cands[0]
135
+ # Honesty by construction: 'mapped' requires the returned concept name to
136
+ # actually match the query (normalized), regardless of which search tier
137
+ # produced it. The search_type label alone is NOT a guarantee that the
138
+ # top hit is faithful, so an 'exact'-tier hit with a mismatched name is
139
+ # demoted to 'review', not silently mapped.
140
+ exactish = _norm(top.get("name")) == _norm(query)
141
+ status = "mapped" if exactish else "review"
142
+ return {
143
+ "status": status,
144
+ "cui": top["cui"],
145
+ "matched_name": top["name"],
146
+ "root_source": top["root_source"],
147
+ "semantic_types": top["semantic_types"],
148
+ "search_type": st,
149
+ "sab_filtered": used_sab,
150
+ "sty_filtered": bool(stys),
151
+ "candidates": [
152
+ {k: c[k] for k in ("cui", "name", "root_source", "semantic_types")}
153
+ for c in cands[:5]
154
+ ],
155
+ }
156
+
157
+
158
+ def _adj_key(dimension: str, token) -> str:
159
+ return f"{dimension}/{token if token is not None else '(axis)'}"
160
+
161
+
162
+ def load_adjudications(path: Path | None = None) -> dict:
163
+ """Curator decisions keyed by 'dimension/token' (or 'dimension/(axis)').
164
+ ``path`` defaults to the CURRENT ``ADJUDICATIONS`` (resolved at call time, so
165
+ tests/workspaces that repoint it are honoured)."""
166
+ path = ADJUDICATIONS if path is None else path
167
+ if not path.is_file():
168
+ return {}
169
+ doc = yaml.safe_load(path.read_text()) or {}
170
+ return doc.get("adjudications", {}) or {}
171
+
172
+
173
+ def apply_adjudication(entry: dict, adj: dict, client=None) -> dict:
174
+ """Apply a curator decision. Honest by construction: an accepted CUI is used
175
+ only if it was among the query's candidates OR the harness can fetch it live
176
+ from UMLS (confirming it is a real concept). The harness never fabricates a
177
+ concept the curator merely named. A curator may also accept a concept found
178
+ via the adjudication UI's live search; such a CUI is fetched here."""
179
+ if adj.get("unmapped"):
180
+ return {**entry, "status": "unmapped", "curated": True,
181
+ "curator_note": adj.get("note")}
182
+ cui = adj.get("accept")
183
+ if cui:
184
+ for c in (entry.get("candidates") or []):
185
+ if c["cui"] == cui:
186
+ return {**entry, "status": "mapped", "curated": True,
187
+ "cui": c["cui"], "matched_name": c["name"],
188
+ "root_source": c["root_source"],
189
+ "semantic_types": c["semantic_types"],
190
+ "curator_note": adj.get("note")}
191
+ # Not among the query candidates: fetch it live to confirm it exists.
192
+ concept = client.get_concept(cui) if client is not None else None
193
+ if concept and concept.get("cui") == cui:
194
+ return {**entry, "status": "mapped", "curated": True, "fetched": True,
195
+ "cui": cui, "matched_name": concept["name"],
196
+ "root_source": concept.get("root_source", "MTH"),
197
+ "semantic_types": concept.get("semantic_types", []),
198
+ "curator_note": adj.get("note")}
199
+ # Cannot confirm the concept: refuse to fabricate it.
200
+ return {**entry, "status": "review", "curated": False,
201
+ "curator_error": f"accepted CUI {cui} not in candidates and not "
202
+ f"confirmable via UMLS"}
203
+ return entry
204
+
205
+
206
+ def build(client, live: bool, inventory_path: Path = INVENTORY,
207
+ adjudications: dict | None = None,
208
+ only_dims: set | None = None) -> dict:
209
+ """Resolve the whole inventory. Each dimension AXIS maps to a UMLS semantic
210
+ type (from adjudication accept_sty, else the inventory's semantic_type), and
211
+ that type's subtree constrains the search for the dimension's VALUE concepts
212
+ -- so axis and values reconcile by construction (a value is only ever mapped
213
+ within its axis's semantic branch).
214
+
215
+ ``only_dims`` restricts resolution to those dimensions (a scoped rebuild);
216
+ the returned doc then contains only their entries, and the caller is
217
+ responsible for merging into a full crosswalk."""
218
+ if adjudications is None:
219
+ adjudications = load_adjudications()
220
+ doc = yaml.safe_load(inventory_path.read_text())
221
+ out_entries = []
222
+ for dim, body in (doc.get("dimensions") or {}).items():
223
+ if only_dims is not None and dim not in only_dims:
224
+ continue
225
+ axis = body.get("axis")
226
+ axis_adj = adjudications.get(_adj_key(dim, None)) or {}
227
+ if axis_adj.get("unmapped"):
228
+ axis_sty = None # curator: axis untyped
229
+ else:
230
+ axis_sty = axis_adj.get("accept_sty") or (axis or {}).get("semantic_type")
231
+ stys = stylib.filter_param(axis_sty) # value-search filter
232
+
233
+ if axis is not None:
234
+ e = _entry(dim, None, "axis", axis)
235
+ if not live:
236
+ e["status"] = "pending"
237
+ elif axis_sty:
238
+ sty = stylib.get(axis_sty)
239
+ if sty:
240
+ e.update({"status": "mapped",
241
+ "curated": bool(axis_adj.get("accept_sty")),
242
+ "sty_tui": sty["tui"], "sty_name": sty["name"],
243
+ "sty_tree": sty.get("tree_number"),
244
+ "curator_note": axis_adj.get("note")})
245
+ else:
246
+ e.update({"status": "review",
247
+ "curator_error": f"semantic type {axis_sty} not in "
248
+ "semantic_types.yaml (run fetch_semantic_network.py)"})
249
+ else:
250
+ e["status"] = "unmapped"
251
+ if axis_adj.get("unmapped"):
252
+ e.update({"curated": True, "curator_note": axis_adj.get("note")})
253
+ out_entries.append(e)
254
+
255
+ for kind, seq in (("value", "values"), ("common_value", "common_values")):
256
+ for v in body.get(seq) or []:
257
+ e = _entry(dim, v["token"], kind, v)
258
+ res = resolve(client, e["query"], e.get("sab"), live, stys=stys)
259
+ entry = {**e, **res}
260
+ adj = adjudications.get(_adj_key(dim, v["token"]))
261
+ if adj and live:
262
+ entry = apply_adjudication(entry, adj, client=client)
263
+ out_entries.append(entry)
264
+
265
+ counts = {s: 0 for s in ("mapped", "review", "unmapped", "pending")}
266
+ for e in out_entries:
267
+ counts[e["status"]] += 1 # KeyError surfaces any unexpected status
268
+ counts["curated"] = sum(1 for e in out_entries if e.get("curated"))
269
+ counts["axis_typed"] = sum(1 for e in out_entries if e.get("sty_tui"))
270
+ return {
271
+ "meta": {
272
+ "generated_by": "mapping/build_umls_crosswalk.py",
273
+ "source_inventory": "mapping/dimensions_inventory.yaml",
274
+ "live": live,
275
+ "total": len(out_entries),
276
+ "counts": counts,
277
+ },
278
+ "entries": out_entries,
279
+ }
280
+
281
+
282
+ # --------------------------------------------------------------------------
283
+ # Inventory <-> SHACL drift check.
284
+ # --------------------------------------------------------------------------
285
+
286
+ def shacl_enum_members() -> set[str]:
287
+ import rdflib
288
+ g = rdflib.Graph().parse(str(SHACL))
289
+ GEM = "https://w3id.org/genetic-evidence-model/"
290
+ enum_classes = {GEM + c for c in (
291
+ "KnowledgeDomain", "Method", "TargetType", "Resolution", "Credibility",
292
+ "PhenotypeScale", "VariantAscertainment", "MeasurementTarget",
293
+ "GeneRelation", "KnockoutType", "Penetrance")}
294
+ rdf_type = rdflib.RDF.type
295
+ members = set()
296
+ for s, _, o in g.triples((None, rdf_type, None)):
297
+ if str(o) in enum_classes and str(s).startswith(GEM):
298
+ members.add(str(s)[len(GEM):])
299
+ return members - ENUM_EXCLUDE
300
+
301
+
302
+ # Dimensions whose values are intentionally NOT in the SHACL enumerations
303
+ # (open / not schema-enumerated): the harness expects inventory tokens here
304
+ # that have no SHACL member.
305
+ OPEN_DIMS = {"subdomain", "mode_of_inheritance"}
306
+
307
+
308
+ def check_inventory() -> int:
309
+ members = shacl_enum_members()
310
+ entries = load_entries()
311
+ inv_tokens = {str(e["token"]) for e in entries if e["token"] is not None}
312
+ missing = sorted(members - inv_tokens)
313
+ # Extra inventory tokens are expected for the open dimensions; any OTHER
314
+ # extra (a token that is neither a SHACL member nor in an open dimension)
315
+ # is surfaced as a warning so a stray/typo'd token does not hide.
316
+ open_tokens = {str(e["token"]) for e in entries
317
+ if e["token"] is not None and e["dimension"] in OPEN_DIMS}
318
+ extra = sorted((inv_tokens - members) - open_tokens)
319
+ if missing:
320
+ print("INVENTORY DRIFT: SHACL enum members not covered by the inventory:")
321
+ for m in missing:
322
+ print(f" - {m}")
323
+ return 1
324
+ print(f"Inventory covers all {len(members)} SHACL enum members "
325
+ f"(excluding sentinel + legacy synonyms).")
326
+ if extra:
327
+ print(f"WARNING: {len(extra)} inventory token(s) outside both the SHACL "
328
+ f"enums and the open dimensions ({', '.join(sorted(OPEN_DIMS))}):")
329
+ for e in extra:
330
+ print(f" - {e}")
331
+ return 0
332
+
333
+
334
+ # --------------------------------------------------------------------------
335
+ # CLI
336
+ # --------------------------------------------------------------------------
337
+
338
+ def _make_client(args):
339
+ if args.check_inventory:
340
+ return None, False
341
+ if args.fixtures:
342
+ from forome.gem.umls.uts_client import FixtureClient
343
+ return FixtureClient(Path(args.fixtures)), True
344
+ key = args.api_key or os.environ.get("UMLS_API_KEY", "")
345
+ if key and not args.no_key:
346
+ from forome.gem.umls.uts_client import UTSClient
347
+ return UTSClient(key, cache_dir=CACHE_DIR), True
348
+ from forome.gem.umls.uts_client import NullClient
349
+ return NullClient(), False
350
+
351
+
352
+ def main(argv=None) -> int:
353
+ ap = argparse.ArgumentParser(description=__doc__,
354
+ formatter_class=argparse.RawDescriptionHelpFormatter)
355
+ ap.add_argument("--api-key", help="UMLS UTS API key (else $UMLS_API_KEY).")
356
+ ap.add_argument("--no-key", action="store_true",
357
+ help="Force scaffold mode: run with no UMLS query (all 'pending').")
358
+ ap.add_argument("--fixtures", help="Directory of canned UTS responses (offline tests).")
359
+ ap.add_argument("--out", default=str(DEFAULT_OUT), help="Output crosswalk YAML path.")
360
+ ap.add_argument("--check-inventory", action="store_true",
361
+ help="Only verify the inventory still covers the SHACL enums; do not query.")
362
+ args = ap.parse_args(argv)
363
+
364
+ if args.check_inventory:
365
+ return check_inventory()
366
+
367
+ client, live = _make_client(args)
368
+ doc = build(client, live)
369
+ Path(args.out).write_text(
370
+ yaml.safe_dump(doc, sort_keys=False, allow_unicode=True, width=100))
371
+
372
+ c = doc["meta"]["counts"]
373
+ print(f"Wrote {args.out}")
374
+ print(f" total={doc['meta']['total']} mapped={c['mapped']} "
375
+ f"review={c['review']} unmapped={c['unmapped']} pending={c['pending']}"
376
+ f" (curated={c.get('curated', 0)})")
377
+ if not live:
378
+ print(" (scaffold mode: no UMLS key supplied -> all entries 'pending'. "
379
+ "Set UMLS_API_KEY and rerun to resolve concepts.)")
380
+ return 0
381
+
382
+
383
+ if __name__ == "__main__":
384
+ raise SystemExit(main())
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env python3
2
+ """Classify the credibility-sweep candidates against the adequacy criteria.
3
+
4
+ Applies the rule families (regular expressions over the concept's preferred
5
+ name) that assign each candidate the criterion it fails -- A denotation,
6
+ B granularity, C set membership, D domain sense -- with a one-line reason.
7
+ Together with ``gem-umls-sweep`` and ``data/umls/sweeps/credibility.yaml``
8
+ this fully regenerates the curated results from any licensed UMLS copy; the
9
+ results themselves are not redistributed (they carry UMLS-derived strings).
10
+
11
+ Usage:
12
+ gem-umls-classify-credibility RESULTS.yaml [--check]
13
+
14
+ ``--check`` reports the classification without writing. A candidate no rule
15
+ matches is left unclassified (``fails: null``) and reported -- extend RULES
16
+ rather than forcing a code.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import re
22
+ import sys
23
+ from collections import Counter
24
+
25
+ import yaml
26
+
27
+ # (criterion, regex on the preferred name, one-line reason). Order matters:
28
+ # specific families come before generic degree words.
29
+ RULES: list[tuple[str, str, str]] = [
30
+ ("C", r"^(Definite|Probable|Possible|Probably|Possibly|Definitely|Unlikely|Certain)(\s*\(.*\))?$|^(Probable|Possible|Definite) diagnosis$",
31
+ "epistemic-modality qualifier (SNOMED diagnostic-certainty family: definite/probable/possible) — the nearest UMLS construct; denotes the likelihood that a proposition holds, not the defeasibility of trust in an evidence item; a three-point ladder with no intensified top, bound in its sources to diagnosis assertions (also D)"),
32
+ ("D", r"Related to Intervention",
33
+ "adverse-event causality attribution ladder (NCI: definitely/probably/possibly related) — degree of belief about causation of one event, not warrant for a research finding"),
34
+ ("D", r"TNM|degree of certainty of|Qualifier for TNM",
35
+ "SNOMED/TNM certainty factor (C-factor): certainty of a staging classification for one patient, a diagnostic-assertion qualifier, not evidence credibility"),
36
+ ("D", r"Level of Diagnostic Certainty|Diagnosis Confidence Level|UHDRS",
37
+ "certainty of a clinical diagnosis (rating-scale item), not warrant for a research finding (also B: names the dimension, not a level)"),
38
+ ("D", r"Level of evidence.*(Bld|Tiss|Molecular|Blood)",
39
+ "LOINC molecular-pathology 'level of evidence' (AMP/ASCO/CAP actionability tier for a variant's clinical significance): a clinical-actionability tier bound to a variant report, not curator warrant for an evidence item (also B: the attribute, not a level)"),
40
+ ("D", r"Metabolite Identification Confidence",
41
+ "metabolomics identification-confidence levels (Schymanski 1–5): an ordered epistemic ladder, but about confidence in a structure identification — domain-bound (D) and defined by analytical evidence type, not defeasibility"),
42
+ ("D", r"^Current level of confidence I can|^Current level of confidence|confidence:Find:Pt|Overall level of confidence with|I am confident|How confident|confident (that|about|in)|self[- ]?efficacy",
43
+ "patient-reported self-efficacy questionnaire item (LOINC/PROMIS): confidence in performing an activity, not warrant about evidence"),
44
+ ("D", r"^(Very High|High|Moderate|Low) Confidence$|^(Very |Somewhat |Not at all |Extremely |Fairly |Quite |Slightly )?Confident$|^Not Confident",
45
+ "NCI Clinical or Research Assessment Answer — a questionnaire response option for a respondent's confidence (Female Sexual Function Index subset), not an epistemic grade of evidence, despite matching GEM's four-level shape"),
46
+ ("D", r"self[- ]?esteem|Self Confidence|^Confidence$|Lack of confidence|Loss of confidence|confidence in (self|abilities)|Euphoric",
47
+ "psychological trait/state, not warrant about evidence"),
48
+ ("A", r"^Level of Evidence( (I|II|III|IV|V)[A-Za-z0-9]*)?$|Level of Evidence [IVX]+|^Evidence Level|^Level of evidence$",
49
+ "NCI/PDQ level-of-evidence: a study-design hierarchy (RCT > cohort > case series), not degree of warrant; mapping to it would collapse GEM's method facet into credibility (also C for the GEM scale)"),
50
+ ("A", r"Level of Confidence \(statistical\)|confidence interval|confidence limit|confidence coefficient|^Statistical",
51
+ "statistical quantity (interval/coefficient), not a degree of belief in evidence"),
52
+ ("A", r"probability|likelihood|risk score|risk category|score$|Score\b|Scale$|Index$|staging category",
53
+ "a quantitative score/probability or risk/staging category of a clinical measurand, not epistemic warrant"),
54
+ ("D", r"[Gg]rade|GRADE",
55
+ "'grade' in a non-epistemic sense (tumour, school, product grade)"),
56
+ ("D", r"[Cc]redib",
57
+ "credibility of a health-information source or witness as perceived by a patient/clinician (LOINC/CHV/NIC), not the curator's warrant for an evidence item"),
58
+ ("D", r"\[X\]|alcohol|[Ee]vidence of|evidence-based practice",
59
+ "clinical 'evidence of <condition>' sense (presence of a sign) or a care activity, not evidence strength"),
60
+ ("A", r"^(High|Very high|Very High|Extremely high|Abnormally high|Highest|Low|Very low|Lowest|Moderate|\(\+\) Moderate|Medium|Minimal|Maximal|Maximum|Minimum|Mild|Moderation|Strong|Weak|Weakness|Markedly increased|Increased|Decreased|Normal|Intermediate)(\s*\(.*\))?$",
61
+ "generic degree/qualifier value (SNOMED qualifier-value branch, IS-A Increased/Decreased): magnitude of a measurand, not epistemic degree"),
62
+ ("A", r"^(A )?(Medium|High|Low|Very high|Very low|Moderate|Minimal|Strong|Weak)( Amount| Level| Degree| Dose| Intensity| Frequency| Quality| Risk| Grade| Priority| Severity| Strength| Concentration| Pressure| Temperature| Volume| Density| Power| Speed| Altitude| Titer)?( of .*)?$",
63
+ "degree of a measured/graded property, not warrant about evidence"),
64
+ ("D", r"[Mm]edia\b|Tunica|Lowing|Lower \(action\)|spatial qualifier|Body Site Modifier|Culture Media",
65
+ "lexical collision (medium/media, lower) — unrelated sense"),
66
+ ("A", r"\b(high|low|moderate|medium|very high|very low|minimal|strong|weak|extremely high|highest|mild|maximal)\b",
67
+ "the qualifier appears as a magnitude/degree of a clinical or physical property (measurand degree), not as a degree of epistemic warrant"),
68
+ ]
69
+
70
+
71
+ def classify(name: str) -> tuple[str, str] | None:
72
+ for code, rx, why in RULES:
73
+ if re.search(rx, name or "", re.I):
74
+ return code, why
75
+ return None
76
+
77
+
78
+ def main(argv=None) -> int:
79
+ ap = argparse.ArgumentParser(prog="gem-umls-classify-credibility",
80
+ description=__doc__.splitlines()[0])
81
+ ap.add_argument("results", help="a gem-umls-sweep results YAML to classify")
82
+ ap.add_argument("--check", action="store_true",
83
+ help="report only; do not write")
84
+ args = ap.parse_args(argv)
85
+ doc = yaml.safe_load(open(args.results))
86
+ dist: Counter = Counter()
87
+ residual = []
88
+ for c in doc.get("candidates") or []:
89
+ hit = classify(c.get("name"))
90
+ if hit is None:
91
+ residual.append(c)
92
+ continue
93
+ c["fails"], c["why"] = hit
94
+ dist[hit[0]] += 1
95
+ print(f"classified {sum(dist.values())} of "
96
+ f"{len(doc.get('candidates') or [])}: {dict(dist)}")
97
+ for c in residual:
98
+ print(f" UNCLASSIFIED {c.get('cui')} {c.get('name')!r} "
99
+ f"{c.get('root_source')}")
100
+ if residual:
101
+ print("extend RULES for the unclassified candidates; nothing written"
102
+ if not args.check else "")
103
+ return 1
104
+ if not args.check:
105
+ yaml.safe_dump(doc, open(args.results, "w"), sort_keys=False,
106
+ allow_unicode=True, width=110)
107
+ print(f"written: {args.results}")
108
+ return 0
109
+
110
+
111
+ if __name__ == "__main__":
112
+ sys.exit(main())
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env python3
2
+ """Fetch the full UMLS Semantic Network (all semantic types + tree numbers) and
3
+ render it as a hierarchical tree.
4
+
5
+ Writes:
6
+ mapping/semantic_types.yaml - the reference data (tui, name, tree, def)
7
+ mapping/semantic_network_tree.md - the tree, annotated with where each GEM
8
+ dimension axis currently *derives* to
9
+ (the modal semantic type of its mapped
10
+ values), so untyped axes are visible.
11
+
12
+ Use this to decide which dimension axes have a faithful UMLS semantic type and
13
+ which are untyped (unmapped). Requires UMLS_API_KEY (env or .envrc).
14
+
15
+ Usage:
16
+ python3 mapping/fetch_semantic_network.py
17
+ python3 mapping/fetch_semantic_network.py --max 220 # TUI scan upper bound
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import re
23
+ from collections import Counter, defaultdict
24
+
25
+ import yaml
26
+
27
+ from forome.gem.umls import build_umls_crosswalk as H
28
+ from forome.gem.umls import adjudicate_ui as UI # reuse api_key()
29
+ from forome.gem.umls.uts_client import UTSClient
30
+ from forome.gem.umls._paths import DATA_DIR
31
+
32
+ TYPES_YAML = DATA_DIR / "semantic_types.yaml"
33
+ TREE_MD = DATA_DIR / "semantic_network_tree.md"
34
+ CROSSWALK = DATA_DIR / "umls_crosswalk.yaml"
35
+
36
+
37
+ def tree_sort_key(tn: str):
38
+ key = []
39
+ for p in (tn or "").split("."):
40
+ m = re.match(r"([A-Za-z]*)(\d+)", p)
41
+ key.append((m.group(1), int(m.group(2))) if m else ("", 0))
42
+ return key
43
+
44
+
45
+ def axis_derivations() -> dict:
46
+ """semantic-type name -> [dimensions whose mapped values are modally that type]."""
47
+ if not CROSSWALK.is_file():
48
+ return {}
49
+ doc = yaml.safe_load(CROSSWALK.read_text())
50
+ per = defaultdict(Counter)
51
+ for e in doc["entries"]:
52
+ if e["kind"] in ("value", "common_value") and e["status"] == "mapped":
53
+ for st in (e.get("semantic_types") or []):
54
+ per[e["dimension"]][st] += 1
55
+ name_to_axes = defaultdict(list)
56
+ for dim, cnt in per.items():
57
+ if cnt:
58
+ name_to_axes[cnt.most_common(1)[0][0]].append(dim)
59
+ return name_to_axes
60
+
61
+
62
+ def fetch_all(client, upper: int) -> list:
63
+ types = []
64
+ for n in range(1, upper + 1):
65
+ st = client.get_semantic_type(f"T{n:03d}")
66
+ if st and st.get("tree_number"):
67
+ types.append(st)
68
+ types.sort(key=lambda s: tree_sort_key(s["tree_number"]))
69
+ return types
70
+
71
+
72
+ def _is_type(s) -> bool:
73
+ return (s["tree_number"] or "").startswith(("A", "B"))
74
+
75
+
76
+ def render_tree(types: list, derivations: dict) -> str:
77
+ sem = [s for s in types if _is_type(s)] # semantic TYPES (A/B)
78
+ rel = [s for s in types if not _is_type(s)] # relations (R)
79
+ lines = ["# UMLS Semantic Network — semantic types tree",
80
+ "",
81
+ f"_{len(sem)} semantic types (plus {len(rel)} relations, listed at "
82
+ "the end). Generated by mapping/fetch_semantic_network.py from the "
83
+ "live UTS API._",
84
+ "",
85
+ "Dimension **axes** map to a semantic **type** (A/B branches below); "
86
+ "dimension **values** map to Metathesaurus concepts. GEM axes are "
87
+ "annotated at the type their mapped values currently *derive* to "
88
+ "(modal). Axes with no typed values are untyped/unmapped candidates "
89
+ "(e.g. `target_type`, `resolution`, `credibility`, "
90
+ "`variant_ascertainment`).",
91
+ "", "## Semantic types", "", "```"]
92
+ for s in sem:
93
+ depth = len((s["tree_number"] or "").split("."))
94
+ indent = " " * (depth - 1)
95
+ axes = derivations.get(s["name"])
96
+ tag = (" <== GEM axis: " + ", ".join(sorted(axes))) if axes else ""
97
+ lines.append(f"{indent}{s['tree_number']:<12} {s['name']} ({s['tui']}){tag}")
98
+ lines += ["```", "", "## Relations (for reference)", "", "```"]
99
+ for s in rel:
100
+ depth = len((s["tree_number"] or "").split("."))
101
+ lines.append(f"{' ' * (depth - 1)}{s['tree_number']:<10} {s['name']} ({s['tui']})")
102
+ lines.append("```")
103
+ return "\n".join(lines) + "\n"
104
+
105
+
106
+ def main(argv=None) -> int:
107
+ ap = argparse.ArgumentParser()
108
+ ap.add_argument("--max", type=int, default=220, help="TUI scan upper bound")
109
+ args = ap.parse_args(argv)
110
+
111
+ key = UI.api_key()
112
+ if not key:
113
+ print("ERROR: no UMLS_API_KEY (env or .envrc).")
114
+ return 1
115
+ client = UTSClient(key, cache_dir=H.CACHE_DIR)
116
+ print(f"Fetching semantic types T001..T{args.max:03d} (cached after first run)...")
117
+ types = fetch_all(client, args.max)
118
+ TYPES_YAML.write_text(yaml.safe_dump(
119
+ {"count": len(types), "source": "UMLS Semantic Network (UTS API)",
120
+ "types": types}, sort_keys=False, allow_unicode=True, width=200))
121
+ tree = render_tree(types, axis_derivations())
122
+ TREE_MD.write_text(tree)
123
+ print(f"Wrote {TYPES_YAML.name} ({len(types)} types) and {TREE_MD.name}.")
124
+ # top-level summary to stdout
125
+ tops = [s for s in types if len((s["tree_number"] or "").split(".")) <= 2]
126
+ print("\nTop-level branches:")
127
+ for s in tops:
128
+ print(f" {s['tree_number']:<8} {s['name']} ({s['tui']})")
129
+ return 0
130
+
131
+
132
+ if __name__ == "__main__":
133
+ raise SystemExit(main())