feat(ogar-obo): agnostic xref→baked-address crosswalk adapter - #231
Conversation
Data-as-config, not a hand-rolled table: a reverse index over the bake's preserved xrefs resolves ANY external code (ICD · FMA · MeSH · SNOMED · OMIM · Orphanet · …) to its baked OBO address (classid + 24-bit identity). One mechanism, every consumer — medcare-rs resolves ICD→MONDO and FMA/DICOM→Uberon through it; odoo-rs can resolve its own codes through the same adapter once its vocabulary is baked (OGAR agnostic connective tissue, never a consumer mirror). resolve_icd10 carries the 2-hop rollup the MEASURED MONDO coverage requires: MONDO xrefs the WHO ICD axis but carries no ICD-10-GM, so a German billing code (E11.9) rolls up its hierarchy to the baked WHO stem (E11) — pure string rollup, no separate GM table. Honest boundary: an unbaked code returns None (can-stay- silent test proves it). reason.rs (the ELK EL-completion port) stays the ORACLE — a reference algorithm for validating the deterministic path and measuring activation, not production data. 3 new tests (any-source resolve, ICD rollup, silence); clippy-clean.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 03dd9ade16
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| pub fn resolve_icd10(&self, code: &str) -> Option<Address> { | ||
| let mut c = code.trim().to_string(); | ||
| loop { | ||
| if let Some(a) = self.resolve(&XrefSource::Icd, &c) { |
There was a problem hiding this comment.
Keep ICD editions distinct during ICD-10 resolution
resolve_icd10 queries the shared XrefSource::Icd bucket, while XrefSource::from_prefix maps ICD-9, ICD-10, and ICD-10-CM into that same variant. When editions reuse a literal code—for example, V10 exists in both ICD-9 and ICD-10—the index cannot tell which edition supplied the xref and may return the ICD-9-linked disease for an ICD-10 lookup. Preserve the ICD edition in the indexed source before exposing an ICD-10-specific resolver.
Useful? React with 👍 / 👎.
| let mut idx = HashMap::with_capacity(bake.xrefs.len()); | ||
| for (id, x) in &bake.xrefs { | ||
| let addr = Address { | ||
| classid: id.namespace().render_classid(0x0000), |
There was a problem hiding this comment.
Return the classid actually written by the bake
When bake was called with a nonzero app_prefix, every emitted row uses that prefix in its classid, but this constructs the returned address with 0x0000. The resulting Address therefore does not identify any row in that bake even though from_bake reports a successful resolution; derive the classid from the corresponding baked row or retain the bake's render prefix.
Useful? React with 👍 / 👎.
| other => (tag(other), x.id.clone()), | ||
| }; | ||
| // first writer wins → stable, deterministic (bake.xrefs is sorted by node) | ||
| idx.entry(key).or_insert(addr); |
There was a problem hiding this comment.
Preserve every target of non-unique xrefs
When two baked terms carry the same external xref—a valid situation for broad ontology mappings—or_insert silently discards every target after the first. Resolution then depends on the lowest sorted TermId rather than the requested concept's semantics, potentially returning a disease where the same UMLS identifier also maps to a phenotype. Store all matching addresses or make ambiguity explicit instead of selecting an arbitrary target.
Useful? React with 👍 / 👎.
…_of / phenotypes_of)
`saturate` computes the aggregate EL closure; a consumer resolving ONE entity
(a disease → its anatomy site + phenotypes, e.g. medcare-first-thought's
ontology grounding) needs the per-term walk. These operate directly on
`parse_obo`'s `id -> node` map, applying the same is_a-saturation +
existential-role-propagation rules to a single subject:
* `ancestors` — transitive is_a closure of a term (self excluded, cycle-safe:
the start id is pre-seeded into `seen` so a self/loop parent never re-emits
the query term), deterministic order, depth-capped.
* `related_via_ancestry` — every `pred` target reachable from the term or its
is_a ancestors (the subject inherits its ancestors' existential edges).
* `anatomy_of` / `phenotypes_of` — the Uberon (Mondo→Uberon = HasLocation) /
HPO (Mondo→Hpo = HasPhenotype) conveniences over that walk, matching the
crate's namespace-pair `classify`.
Additive over the merged #231 crosswalk/saturate work; no change to the bake,
crosswalk, concept-ids, or `saturate`. 1 new unit test (ancestry + inherited
anatomy + own phenotype + self-exclusion).
Generated by [Claude Code](https://claude.com/claude-code)
Agnostic xref → baked-address crosswalk (data-as-config)
Adds
ogar-obo::crosswalk— one reverse-index mechanism that resolves anyexternal code (ICD · FMA · MeSH · SNOMED · OMIM · Orphanet · …) to its baked
OBO address (
classid+ 24-bit identity), built from the parsedBake'spreserved xrefs. No per-consumer hand-rolled table; the same adapter medcare-rs
uses for ICD→MONDO and FMA/DICOM→Uberon, odoo-rs can later use for its own
account codes once its vocabulary is baked.
What it gives consumers
Crosswalk::from_bake(&bake)— O(1) reverse index overbake.xrefs.resolve(source, code)— agnostic lookup;resolve_fma(id)— glass-patienthelix-zone → Uberon;
resolve_icd10(code)— 2-hop GM→WHO rollup (measured:MONDO carries the WHO axis densely but no ICD-10-GM, so
E11.9 → E11).ANATOMY_CLASSID/DISEASE_CLASSIDconstants for confirm-the-lane checks.Discipline
OBO-public.
predicate import), clippy-clean.
🤖 Generated with Claude Code