phenoforge.engine¶
The retrieval and expansion engine. No I/O to MCP or LLMs (AGENTS.md’s
layering rule) — mcp and agent both import this, never the reverse.
phenoforge.engine.models¶
Pydantic models shared across the engine, MCP server, and agent.
Every code set carries provenance: a concept set is never a bare list of codes, every concept carries where it came from, and terms that could not be resolved are reported rather than silently dropped. This is the project’s anti-hallucination commitment — retrofitting it after the fact would mean changing every caller’s return type.
- class phenoforge.engine.models.ProvenanceTier(value)[source]¶
Bases:
StrEnumTrust tier for a concept’s inclusion in a set.
- Variables:
CURATED – From the OHDSI Phenotype Library. Use as-is, citing the cohort ID and operating characteristics. Not yet produced by anything in this codebase.
PUBLISHED – From a validated published algorithm in the literature. Deferred to a later version — not yet produced by this codebase.
GENERATED – From semantic/BM25 retrieval or hierarchy expansion. Ungrounded — requires human confirmation before use.
- CURATED = 'curated'¶
- PUBLISHED = 'published'¶
- GENERATED = 'generated'¶
- class phenoforge.engine.models.Concept(**data)[source]¶
Bases:
BaseModelA single vocabulary concept, as loaded into
vocab.duckdb.- Variables:
concept_id – OMOP concept id.
concept_code – Vocabulary-native code, e.g.
"E11.21".concept_name – Human-readable concept name.
domain_id – OMOP domain, e.g.
"Condition".vocabulary_id – Source vocabulary. SNOMED rows are loaded locally for future curated-set resolution but are never a lookup or search target — only
"ICD10CM"is ever surfaced outside the engine.
- Parameters:
- class phenoforge.engine.models.ConceptWithProvenance(**data)[source]¶
Bases:
ConceptA concept annotated with why it is in a result set.
- Variables:
tier – The trust tier this concept was included under.
source – Free-text description of the retrieval step that produced this concept, e.g.
"hierarchy_expansion:E11"or"bm25:diabetic nephropathy". Not machine-parsed; exists for human review and debugging.
- Parameters:
- tier: ProvenanceTier¶
- class phenoforge.engine.models.UnmappableTerm(**data)[source]¶
Bases:
BaseModelA query term that could not be resolved to any concept.
- Variables:
term – The term or phrase that failed to resolve.
reason – Why it failed, e.g.
"no BM25 match above threshold".
- Parameters:
- class phenoforge.engine.models.ConceptSet(**data)[source]¶
Bases:
BaseModelA retrieval or expansion result.
Deliberately not a bare list of codes (AGENTS.md): every concept carries provenance, and unresolved terms are surfaced rather than dropped.
- Variables:
concepts – Concepts included in the set, each with its provenance.
unmappable – Terms that could not be resolved to any concept.
- Parameters:
concepts (list[ConceptWithProvenance])
unmappable (list[UnmappableTerm])
- concepts: list[ConceptWithProvenance]¶
- unmappable: list[UnmappableTerm]¶
phenoforge.engine.db¶
Read-only connection helper for vocab.duckdb.
No I/O to MCP or LLMs lives in this package (AGENTS.md layering rule) — this
module only knows how to open the database built by scripts/load_vocab.py.
- phenoforge.engine.db.connect(db_path=PosixPath('data/vocab.duckdb'))[source]¶
Open a read-only connection to the vocabulary database.
- Parameters:
db_path (
Path) – Path to a DuckDB database built byscripts/load_vocab.py.- Returns:
An open, read-only DuckDB connection.
- Return type:
duckdb.DuckDBPyConnection
- Raises:
FileNotFoundError – If
db_pathdoes not exist.
phenoforge.engine.lookup¶
Direct concept_code lookup against the ICD-10-CM vocabulary.
phenoforge.engine.retrieval¶
BM25 lexical retrieval over ICD-10-CM concept names.
One of three planned retrieval components — dense embeddings (paraphrase
matching) and graph traversal (hierarchy expansion, see expansion.py)
are the other two. BM25 covers exact strings, code fragments, and rare
literal terms; it does not match paraphrase or clinical synonymy.
- class phenoforge.engine.retrieval.BM25Retriever(con)[source]¶
Bases:
objectA BM25 index over ICD-10-CM
concept_nametext.Construction loads and tokenizes every ICD-10-CM concept name, so build one instance per process (e.g. once at MCP server startup) rather than per query.
- Variables:
concept_ids – OMOP concept ids, parallel to the internal corpus.
- Parameters:
con (duckdb.DuckDBPyConnection)
- search(query, k=10, min_score=0.0)[source]¶
Search for concepts whose names best match a free-text query.
- Parameters:
- Returns:
A tuple of (matching concepts tagged
GENERATED, ordered by descending score; anUnmappableTermif nothing scored abovemin_score, elseNone).- Return type:
tuple[list[ConceptWithProvenance], UnmappableTerm | None]
phenoforge.engine.dense¶
Dense (semantic) retrieval over ICD-10-CM concept names via a LanceDB index.
Third of three retrieval components alongside BM25 (retrieval.py) and graph traversal (expansion.py); covers paraphrase/synonymy that BM25 misses since it matches on meaning rather than shared words.
The embedding function is injectable (EmbedFn) so tests never need to
download a real model — see tests/engine/fake_embedder.py. Production
code gets a real embedder via default_embedder(), which lazily imports
sentence_transformers so importing this module never requires it.
- phenoforge.engine.dense.default_embedder()[source]¶
Build an embedding function backed by real BioLORD-2023.
Imports
sentence_transformersand loads the model on call, not at module import time, soimport phenoforge.engine.densenever requires the dependency to be installed or the model to be downloadable.- Returns:
A function embedding a batch of texts into L2-normalized BioLORD-2023 vectors (see
EmbedFn).- Return type:
EmbedFn
- Raises:
ImportError – If
sentence-transformersis not installed.
- class phenoforge.engine.dense.DenseRetriever(con, embed_fn=None, index_path=None)[source]¶
Bases:
objectA LanceDB-backed semantic index over ICD-10-CM
concept_nametext.Two modes: if
index_pathis given and already exists, opens the persisted table built byscripts/build_index.py(the production path — LanceDB’s value is a persisted, memory-mapped index, unlikeBM25Retriever, which is cheap enough to rebuild every process start). If not, builds an ephemeral in-memory table via_embed_concept_rows()(the test path, with an injected fake embedder — no model download or prebuilt artifact needed).- Parameters:
con (duckdb.DuckDBPyConnection)
embed_fn (EmbedFn | None)
index_path (Path | None)
- search(query, k=10, min_score=0.0)[source]¶
Search for concepts whose names are semantically closest to a query.
- Parameters:
- Returns:
A tuple of (matching concepts tagged
GENERATED, ordered by descending similarity; anUnmappableTermif nothing scored abovemin_score, elseNone).- Return type:
tuple[list[ConceptWithProvenance], UnmappableTerm | None]
phenoforge.engine.hybrid¶
Fuse ranked concept lists from multiple retrieval components.
reciprocal_rank_fusion is a pure function, no DB or network dependency.
hybrid_search is the orchestration layer on top of it — deciding
whether to query a dense retriever at all and building the fallback
unmappable entry — which belongs here rather than in the MCP transport
per AGENTS.md’s thin-server rule (mcp/ is tool registration only, no
business logic).
- phenoforge.engine.hybrid.reciprocal_rank_fusion(result_lists, k=10, rrf_k=60, weights=None)[source]¶
Fuse ranked concept lists by reciprocal rank.
Score for a concept is the sum of
weight * 1 / (rrf_k + rank)across every input list it appears in (rank is 0-indexed position within that list).rrf_k=60is the standard default from the original RRF literature — it sidesteps needing to normalize scores across retrievers whose raw scores aren’t comparable (BM25 term-frequency scores vs. cosine similarity), since RRF only uses rank, not magnitude.A concept appearing in multiple lists is deduplicated by
concept_id, keeping theConceptWithProvenanceinstance from whichever list ranked it highest — itssourcetherefore names the specific retriever that surfaced it best (e.g."dense:..."or"bm25:..."), never a synthesized “hybrid” label, so provenance stays traceable to a real mechanism.- Parameters:
result_lists (
list[list[ConceptWithProvenance]]) – Ranked concept lists to fuse, each already ordered best-first.k (
int) – Maximum number of fused results to return.rrf_k (
int) – RRF rank-damping constant.weights (
list[float] |None) – Per-list multiplier on each list’s RRF contribution, same length and order asresult_lists. Defaults to1.0for every list (the original unweighted behavior — uniform scaling never changes relative order, so this is a strict superset of the prior signature, not a behavior change for existing callers).
- Returns:
Top-
kconcepts by fused score, descending.- Return type:
- phenoforge.engine.hybrid.hybrid_search(bm25, dense, query, k=10, dense_weight=0.5, candidate_k=None)[source]¶
Search using BM25 and, if available, dense retrieval, fused by RRF.
denseis optional so a caller (the MCP server) can passNonewhen no dense index has been built yet, degrading to BM25-only rather than requiring one. Same return contract assearch()andsearch().- Parameters:
bm25 (
BM25Retriever) – A built BM25 retriever.dense (
DenseRetriever|None) – A built dense retriever, orNoneto search lexically only.query (
str) – Free-text search string.k (
int) – Maximum number of fused results to return.dense_weight (
float) – Dense’s share of the fused RRF score, in[0, 1]; BM25 gets1 - dense_weight.0.5(equal weight) matches this function’s original, unweighted behavior — uniform scaling of both lists never changes relative order, so the default is a strict no-op for existing callers. Ignored (BM25-only) whendenseisNone. Seescripts/sweep_hybrid_weights.pyfor measuring whether a different value scores better against curated ground truth before changing this default.candidate_k (
int|None) – How many results each sub-retriever searches before fusion, independent ofk(how many fused results come back). Defaults to5 * k: a concept that one sub-retriever ranks well outsidek(e.g. dense rank 5 of 50 for a seed term with zero BM25 lexical overlap) still gets a chance to compete in RRF fusion rather than being invisible to it, which silently happened under the old default ofkitself (found via cohort775— “inflammatory bowel disease” has no matching ICD-10-CM concept name at all, so BM25 contributes nothing, and the correct code’s decent-but-not-top-kdense rank was never pooled). Widening the pool only ever adds candidates for fusion to rank from; it never changes how many results come back (stillk) or demotes a result that was already going to win, so this is a pure ranking-quality improvement, not a behavior change any caller needs to opt into. Pass an explicit, fixedcandidate_kwhenever comparing a concept’s rank across calls with differentk(e.g.explain_inclusion()’s evidence) to keep that comparison meaningful — or whenkis already large and5 * kwould be wastefully deep.
- Returns:
A tuple of (fused, deduplicated results ordered by combined relevance; an
UnmappableTermif nothing matched, elseNone).- Return type:
tuple[list[ConceptWithProvenance], UnmappableTerm | None]
phenoforge.engine.expansion¶
Hierarchy expansion over CONCEPT_RELATIONSHIP Is a edges.
OMOP’s precomputed transitive-closure table, CONCEPT_ANCESTOR, has zero
rows for ICD-10-CM, so expansion instead walks direct Is a/Subsumes
edges via a recursive CTE. The real Athena download contains redundant
direct edges (e.g. a code’s grandparent is also a direct parent) — not
cycles, but multiple paths to the same concept — so every query here carries
a visited-path guard and the result is deduplicated to each concept’s
shortest depth.
Two directions: expand_descendants() walks down (seed -> more specific
codes), expand_ancestors() walks up (seed -> less specific codes, to
the root). The latter powers hierarchy-distance scoring in
phenoforge.eval.
- phenoforge.engine.expansion.expand_descendants(con, seed_code)[source]¶
Expand an ICD-10-CM seed code to all of its hierarchy descendants.
A descendant of
concept_id_2is any conceptconcept_id_1with anIs aedge to it (direct or transitive) — i.e. traversal followsIs aedges backwards from the seed, equivalentlySubsumesforward from it.
- phenoforge.engine.expansion.expand_ancestors(con, seed_code)[source]¶
Walk an ICD-10-CM concept’s
Is aancestor chain to the root.Counterpart to
expand_descendants(), walking the same edges in the opposite direction (concept_id_1“Is a”concept_id_2meansconcept_id_2is the parent).- Parameters:
con (
DuckDBPyConnection) – Open connection tovocab.duckdb.seed_code (
str) – ICD-10-CM code to walk ancestors from, e.g."E11.21".
- Returns:
Ancestor concepts tagged with
GENERATEDprovenance, ordered by ascending distance from the seed. Empty ifseed_codedoes not exist or is already a root.- Return type:
- phenoforge.engine.expansion.ancestor_chain_ids(con, seed_code)[source]¶
Return
seed_code’s own concept id followed by its ancestor ids, nearest first.Thin wrapper around
expand_ancestors()returning bare ids (self-inclusive, at distance 0) — the shape hierarchy-distance scoring inphenoforge.evalneeds, without forcing every caller through the fullConceptWithProvenancemodel.Concepts with multiple direct parents (real in the Athena data — see
expand_ancestors()) make this a flattened traversal of what’s actually a DAG, not a strict linear chain; ties in depth are broken byconcept_idin the underlying query so the same seed always produces the same chain (needed forphenoforge.eval.metrics.tree_distance()to be reproducible run to run — an earlier version of this query had no tiebreaker and silently returned differently-ordered chains across identical calls).
phenoforge.engine.curated¶
Curated OHDSI Phenotype Library resolution to ICD-10-CM.
Extracts concept-set (not temporal/inclusion-rule) content from Circe-shaped
cohort JSON, resolves each SNOMED concept id to ICD-10-CM via the
Mapped from edges loaded by scripts/load_vocab.py, and applies a
fan-out guard so a broad SNOMED grouper in a cohort definition doesn’t
silently expand into thousands of unrelated ICD-10-CM codes.
A free-text query is matched to a bundled cohort via
find_curated_definition(), which searches the same BM25/dense
retrieval used for generated results against the full ICD-10-CM
vocabulary and checks whether the best hit is actually a member of a
cohort’s resolved code list — not, as an earlier version of this did, by
comparing the query against cohort display names (which let one
incidental shared word confidently mismatch an unrelated cohort).
- phenoforge.engine.curated.find_high_fanout_snomed_concepts(con, fanout_threshold)[source]¶
Flag SNOMED concepts whose
Mapped fromfan-out into ICD-10-CM is excessive.Verified against the v2026 Athena download: fan-out ranges 1..3029, with a median of 2. The high tail is structural (a high-level SNOMED grouper mapping to thousands of unrelated ICD-10-CM codes), not clinical specificity, and should be surfaced for review rather than expanded blindly at resolution time. Used both by the vocabulary loader (to flag broad mappings at build time) and by curated-set resolution (to skip them at resolution time).
- class phenoforge.engine.curated.CuratedCohortConceptItem(**data)[source]¶
Bases:
BaseModelOne item within a cohort’s concept set, extracted from Circe JSON.
- Variables:
concept_id – Source-vocabulary OMOP concept id (as recorded in the cohort’s own vocabulary snapshot — not necessarily present in the locally loaded
concepttable).concept_code – Vocabulary-native code, e.g. a SNOMED code.
vocabulary_id – Source vocabulary of the item, e.g.
"SNOMED".is_excluded – Circe’s
isExcludedflag. Excluded items are dropped entirely during resolution (seeresolve_curated_concepts()).include_descendants – Circe’s
includeDescendantsflag. Not expanded during resolution (seeresolve_curated_concepts()).
- Parameters:
- phenoforge.engine.curated.load_cohort_concept_items(cohort_json_path)[source]¶
Parse a Circe cohort JSON file’s
ConceptSetsinto flat items.Extracts only the concept-set portion of the cohort definition — Circe cohort JSON also carries temporal windows and inclusion-rule logic (
PrimaryCriteria,InclusionRules, etc.), which is out of scope and not read here.Items missing
conceptor any of its required fields are skipped rather than raising — this file is fetched from an external source (scripts/fetch_phenotype_library.py) with no schema guarantee, and a single malformed item should degrade gracefully, not crash the whole lookup.- Parameters:
cohort_json_path (
Path) – Path to one cohort’s JSON file.- Returns:
Flattened concept-set items across all of the cohort’s concept sets. Malformed items are silently omitted.
- Return type:
- phenoforge.engine.curated.resolve_curated_concepts(con, cohort_id, cohort_name, items, fanout_threshold=100)[source]¶
Resolve a cohort’s SNOMED concept-set items to ICD-10-CM.
Per non-excluded item:
vocabulary_id != 'SNOMED': reported as unmappable — RxNorm/LOINC items in a cohort definition are out of scope (only the SNOMED to ICD-10-CM mapping is loaded).fan-out over
fanout_threshold(seefind_high_fanout_snomed_concepts()): reported as unmappable rather than resolved, so a broad SNOMED grouper doesn’t silently expand the curated set into thousands of unrelated codes.otherwise: resolved via
concept_relationship'Mapped from'edges (concept_id_1= SNOMED,concept_id_2= ICD-10-CM), taggedCURATED.
is_excludeditems are dropped entirely —ConceptSethas no exclusion concept, so silently omitting them is more honest than forcing them intounmappable.include_descendantsis not expanded: doing so would require a SNOMED-side hierarchy traversal, which is out of scope; this is a conservative simplification (it under-counts rather than over-includes).- Parameters:
con (
DuckDBPyConnection) – Open connection tovocab.duckdb.cohort_id (
str) – The cohort’s OHDSI Phenotype Library id, used in provenance.cohort_name (
str) – The cohort’s display name, used in provenance.items (
list[CuratedCohortConceptItem]) – Concept-set items fromload_cohort_concept_items().fanout_threshold (
int) – Passed through tofind_high_fanout_snomed_concepts().
- Returns:
Resolved ICD-10-CM concepts tagged
curated, deduplicated byconcept_id(two cohort items can map to the same code), and unmappable items with their reasons.- Return type:
- phenoforge.engine.curated.load_curated_concept_set(con, cohort_id, library_dir, fanout_threshold=100)[source]¶
Load one cohort’s JSON + manifest name from
library_dirand resolve it.- Parameters:
con (
DuckDBPyConnection) – Open connection tovocab.duckdb.cohort_id (
str) – The cohort id, e.g."503". Expects{library_dir}/{cohort_id}.jsonand amanifest.jsonmapping ids to display names, both written byscripts/fetch_phenotype_library.py.library_dir (
Path) – Directory containing fetched cohort JSON files.fanout_threshold (
int) – Passed through toresolve_curated_concepts().
- Returns:
The resolved concept set.
- Return type:
- Raises:
FileNotFoundError – If the cohort JSON or manifest is missing.
- phenoforge.engine.curated.find_curated_definition(con, query, library_dir, bm25, dense=None, fanout_threshold=100, k=10)[source]¶
Find and resolve the OHDSI Phenotype Library cohort whose own codes best match a query.
Runs the same BM25 (+ dense, if given) search used for
generatedretrieval against the full ICD-10-CM vocabulary, then walks the ranked results looking for the best-ranked one that is actually a member of a bundled cohort’s resolved code list — matching on real curated code content rather than a cohort’s display name, which (found via a real end-to-end run) let a single incidental shared word like “diabetic” curated-match “diabetic nephropathy” to an unrelated “Diabetic ketoacidosis” cohort. A hit belonging to more than one cohort is deliberately treated as no match rather than guessed between them, for the same reason: acuratedresult claims to be safe to use as-is, and an ambiguous one would misrepresent that.- Parameters:
con (
DuckDBPyConnection) – Open connection tovocab.duckdb.query (
str) – Free-text population description or seed clinical term.library_dir (
Path) – Directory of fetched OHDSI Phenotype Library cohorts (scripts/fetch_phenotype_library.pyoutput).bm25 (
BM25Retriever) – A built BM25 retriever over ICD-10-CM concept names — shared withhybrid_search()’s other callers rather than built fresh here.dense (
DenseRetriever|None) – A built dense retriever, orNoneto search lexically only, matchinghybrid_search()’s existing fallback.fanout_threshold (
int) – Passed through to cohort resolution.k (
int) – How many top hybrid-search results to check for cohort membership before giving up.
- Returns:
The matched cohort’s resolved
curatedconcepts, or an empty set with one explanatoryUnmappableTermif the library hasn’t been fetched, nothing in the topkresults belongs to a cohort, or the best hit is claimed by more than one cohort. A cohort with a stale manifest entry (missing JSON file) simply can never be matched — see_build_cohort_membership().- Return type:
phenoforge.engine.explain¶
Explain why a concept code would be included in a result, via which path.
MCP tool calls are stateless — nothing persists a previously-returned
ConceptSet server-side, so “why is this code in my set” has to be
answered by re-deriving it: re-running the same curated/generated/
hierarchy resolution a caller would have gotten, and reporting which of
those paths actually produce concept_code, with evidence specific to
each path (a cohort citation, a search rank, a hierarchy distance). A code
can legitimately be reachable by more than one path at once — e.g. both
curated and hierarchy expansion — so every path checked is reported, not
just the first hit.
- class phenoforge.engine.explain.InclusionPath(**data)[source]¶
Bases:
BaseModelOne way
concept_codewas found for the query/seed checked.- Variables:
tier –
ProvenanceTierthis path produces.source – The same provenance string a real result would carry, e.g.
"bm25:diabetic nephropathy"or"ohdsi_pl:503:Type 2 diabetes mellitus".evidence – Human-readable detail specific to this path — a cohort citation, a search rank, or a hierarchy distance.
- Parameters:
tier (ProvenanceTier)
source (str)
evidence (str)
- tier: ProvenanceTier¶
- class phenoforge.engine.explain.InclusionExplanation(**data)[source]¶
Bases:
BaseModelEvery way
concept_codewas found, across every path checked.- Variables:
concept_code – The code that was explained.
concept_name – Its vocabulary display name, or
Noneif the code doesn’t exist in the loaded ICD-10-CM vocabulary at all.paths – One entry per path that actually produced this code; empty if none did (including if the code doesn’t exist).
- Parameters:
concept_code (str)
concept_name (str | None)
paths (list[InclusionPath])
- paths: list[InclusionPath]¶
- phenoforge.engine.explain.explain_inclusion(con, concept_code, bm25, library_dir, dense=None, query=None, seed_code=None, fanout_threshold=100, k=25)[source]¶
Check every applicable path for whether it produces
concept_code.Checks are independent and additive, mirroring the two distinct ways a caller actually reaches a code through this project’s tool surface — pass whichever parameters apply to what’s being explained:
querygiven: checksfind_curated_definition()(curated) andhybrid_search()(generated), the same two callsfind_curated_definition/search_concepts(the MCP tools) make.seed_codegiven: checksexpand_descendants()(generated, via hierarchy), the same callexpand_hierarchy(the MCP tool) makes.
- Parameters:
con (
DuckDBPyConnection) – Open connection tovocab.duckdb.concept_code (
str) – ICD-10-CM code to explain.bm25 (
BM25Retriever) – Shared BM25 index.library_dir (
Path) – Directory of fetched OHDSI Phenotype Library cohorts.dense (
DenseRetriever|None) – Pre-built dense retriever, orNoneto degrade the generated-tier check to BM25-only, matchingsearch_concepts’s existing fallback.query (
str|None) – Free-text query to check curated/generated paths against.seed_code (
str|None) – ICD-10-CM seed code to check the hierarchy-expansion path against.fanout_threshold (
int) – Passed through to curated resolution.k (
int) – How deep to search forconcept_codeon the curated and generated paths — passed tohybrid_search()as itsk(final result count);candidate_kis left athybrid_search’s own default (5 * k) rather than pinned tokhere, so this function’s reported rank benefits from the same widened fusion pool as every other caller. The rank reported inevidenceis the code’s position within this many candidates; pass a largerkto find a code that didn’t show up at a shallower search depth, and don’t compare ranks from calls with differentkdirectly, since they describe different-sized pools.
- Returns:
Every path that produced
concept_code; emptypathsif none did, including if the code isn’t in the vocabulary at all.- Return type: