From 7f8fa59ede6dc2ca6eed00b3979fd14d3731eb34 Mon Sep 17 00:00:00 2001 From: SkyeAv Date: Fri, 17 Jul 2026 13:15:50 -0700 Subject: [PATCH 1/5] feat: added agent of empires and opencode artifacts to the gitignore --- .gitignore | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitignore b/.gitignore index 43d02de..e01d077 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,11 @@ notebooks/PathFinder_testing_results/ notebooks/Test/ dist/ notebooks/PathFinder_results/ + +# opencode artifacts +/.opencode/ +/AGENTS.md +/.logs/ + +# agent-of-empires artifacts +/.agent-of-empires/ From 90b3240f2840adb25683e468ebc22585143f8427 Mon Sep 17 00:00:00 2001 From: SkyeAv Date: Fri, 17 Jul 2026 14:13:33 -0700 Subject: [PATCH 2/5] feat: prototyped experimental API and fixed outdated node norm tests --- TCT/experimental.py | 598 ++++++++++++++++++++++ notebooks/Experimental_API_tutorial.ipynb | 178 +++++++ tests/test_experimental.py | 307 +++++++++++ tests/test_nodenorm.py | 167 +++--- uv.lock | 52 +- 5 files changed, 1202 insertions(+), 100 deletions(-) create mode 100644 TCT/experimental.py create mode 100644 notebooks/Experimental_API_tutorial.ipynb create mode 100644 tests/test_experimental.py diff --git a/TCT/experimental.py b/TCT/experimental.py new file mode 100644 index 0000000..234c156 --- /dev/null +++ b/TCT/experimental.py @@ -0,0 +1,598 @@ +""" +Experimental developer-friendly APIs for common Translator finder patterns. + +This module provides higher-level wrappers around the existing pathfinder and +neighborhood finder code. The wrappers resolve human-readable names, normalize +CURIEs, load Translator resources lazily, and return a small result object with +the most useful output fields surfaced directly. +""" + +from dataclasses import dataclass +from typing import Any, Optional, TypeAlias, Union + +import pandas as pd + +from . import name_resolver, node_normalizer, translator_query +from .TCT import sele_predicates_API +from .TCT_neighborhood_finder import ( + parse_results_for_neighborhood_finder, + parse_results_for_neighborhood_finder_multiple_inputs, +) +from .TCT_pathfinder import parse_results_for_pathfinder + + +NodeInput: TypeAlias = str +CategoryInput: TypeAlias = str +CategoryList: TypeAlias = list[CategoryInput] + + +@dataclass(frozen=True) +class ResolvedNode: + """Resolved node metadata used by the experimental finder APIs.""" + + input_value: str + curie: str + label: Optional[str] + categories: list[str] + + +@dataclass +class TranslatorResources: + """Translator API metadata required by the legacy query functions.""" + + api_names: dict[str, str] + meta_kg: pd.DataFrame + api_predicates: dict[str, list[str]] + + +@dataclass +class FinderResult: + """Convenience wrapper around a parsed TRAPI-style finder response.""" + + query: dict[str, Any] + knowledge_graph: dict[str, Any] + results: list[dict[str, Any]] + auxiliary_graphs: dict[str, Any] + resolved_nodes: dict[str, ResolvedNode] + raw: dict[str, Any] + + def to_dict(self) -> dict[str, Any]: + """ + Return the raw parsed TRAPI-style output dictionary. + + Returns + ------- + dict + Full parsed output generated by the existing finder parser. + + Examples + -------- + >>> result = FinderResult({}, {}, [], {}, {}, {}) + >>> result.to_dict() + {} + """ + return self.raw + + +_DEFAULT_TRANSLATOR_RESOURCES: Optional[TranslatorResources] = None + + +def get_translator_resources(*, refresh: bool = False) -> TranslatorResources: + """ + Return cached Translator API metadata, loading it on first use. + + Parameters + ---------- + refresh : bool + If true, refetch SmartAPI/MetaKG data even when the singleton is + already populated. + + Returns + ------- + TranslatorResources + API names, MetaKG dataframe, and API predicate mapping used by the + legacy query functions. + + Examples + -------- + >>> resources = get_translator_resources() + >>> paths = pathfinder("asthma", "albuterol", ["Gene"], resources=resources) + """ + global _DEFAULT_TRANSLATOR_RESOURCES + if refresh or _DEFAULT_TRANSLATOR_RESOURCES is None: + api_names, meta_kg, api_predicates = ( + translator_query.get_translator_API_predicates() + ) + _DEFAULT_TRANSLATOR_RESOURCES = TranslatorResources( + api_names=api_names, + meta_kg=meta_kg, + api_predicates=api_predicates, + ) + return _DEFAULT_TRANSLATOR_RESOURCES + + +def clear_translator_resource_cache() -> None: + """ + Clear the in-memory Translator resource singleton. + + Examples + -------- + >>> clear_translator_resource_cache() + >>> resources = get_translator_resources() # refetches + """ + global _DEFAULT_TRANSLATOR_RESOURCES + _DEFAULT_TRANSLATOR_RESOURCES = None + + +def pathfinder( + start: NodeInput, + end: NodeInput, + intermediate_categories: CategoryList, + *, + start_categories: Optional[CategoryList] = None, + end_categories: Optional[CategoryList] = None, + api_names: Optional[dict[str, str]] = None, + meta_kg: Optional[pd.DataFrame] = None, + api_predicates: Optional[dict[str, list[str]]] = None, + resources: Optional[TranslatorResources] = None, + scoring_method: str = "infores", + name_resolver_kwargs: Optional[dict[str, Any]] = None, + node_normalizer_kwargs: Optional[dict[str, Any]] = None, +) -> FinderResult: + """ + Find paths between two biomedical concepts using Translator KPs. + + Parameters + ---------- + start : str + Start node as either a CURIE (for example, ``"MONDO:0004979"``) or a + human-readable string (for example, ``"asthma"``). + end : str + End node as either a CURIE or human-readable string. + intermediate_categories : list[str] + Allowed categories for intermediate path nodes. Values may be short + names like ``"Gene"`` or full Biolink names like ``"biolink:Gene"``. + start_categories : list[str], optional + Category override for the start node. If omitted, categories are + inferred from Node Normalizer. + end_categories : list[str], optional + Category override for the end node. If omitted, categories are inferred + from Node Normalizer. + resources : TranslatorResources, optional + Preloaded Translator resources. If omitted, the module-level singleton + is loaded on first use and reused. + api_names, meta_kg, api_predicates : optional + Advanced partial overrides for the Translator resources used by the + legacy pathfinder implementation. + scoring_method : str + Scoring method passed to the legacy parser. Current values are + ``"infores"`` and ``"edges"``. + name_resolver_kwargs : dict, optional + Extra keyword arguments for ``name_resolver.lookup``. + node_normalizer_kwargs : dict, optional + Extra keyword arguments for ``node_normalizer.get_normalized_nodes``. + + Returns + ------- + FinderResult + Convenience wrapper containing resolved input nodes, the parsed + knowledge graph, results, auxiliary graphs, and the raw TRAPI-style + output dictionary. + + Examples + -------- + >>> from TCT.experimental import pathfinder + >>> result = pathfinder("asthma", "albuterol", ["Gene"]) + >>> result.resolved_nodes["start"].curie + 'MONDO:0004979' + """ + start_node = _resolve_node( + start, + name_resolver_kwargs=name_resolver_kwargs, + node_normalizer_kwargs=node_normalizer_kwargs, + ) + end_node = _resolve_node( + end, + name_resolver_kwargs=name_resolver_kwargs, + node_normalizer_kwargs=node_normalizer_kwargs, + ) + intermediate_categories = _normalize_categories(intermediate_categories) or [] + start_categories = _normalize_categories(start_categories) or start_node.categories + end_categories = _normalize_categories(end_categories) or end_node.categories + resolved_resources = _get_resources( + resources=resources, + api_names=api_names, + meta_kg=meta_kg, + api_predicates=api_predicates, + ) + + predicates1, apis1, _ = sele_predicates_API( + start_categories, + intermediate_categories, + resolved_resources.meta_kg, + resolved_resources.api_names, + ) + predicates2, apis2, _ = sele_predicates_API( + intermediate_categories, + end_categories, + resolved_resources.meta_kg, + resolved_resources.api_names, + ) + query1 = translator_query.format_query_json( + [start_node.curie], + [], + start_categories, + intermediate_categories, + predicates1, + ) + query2 = translator_query.format_query_json( + [], + [end_node.curie], + intermediate_categories, + end_categories, + predicates2, + ) + result1 = translator_query.parallel_api_query( + query_json=query1, + select_APIs=apis1, + APInames=resolved_resources.api_names, + API_predicates=resolved_resources.api_predicates, + max_workers=max(1, len(apis1)), + ) + result2 = translator_query.parallel_api_query( + query_json=query2, + select_APIs=apis2, + APInames=resolved_resources.api_names, + API_predicates=resolved_resources.api_predicates, + max_workers=max(1, len(apis2)), + ) + raw_output = parse_results_for_pathfinder( + start_node.curie, + end_node.curie, + result1, + result2, + start_node_categories=start_categories, + end_node_categories=end_categories, + scoring_method=scoring_method, + get_node_info=True, + ) + return _build_finder_result( + raw_output, + resolved_nodes={"start": start_node, "end": end_node}, + ) + + +def neighborhood_finder( + node: Union[NodeInput, list[NodeInput]], + neighbor_categories: CategoryList, + *, + node_categories: Optional[CategoryList] = None, + api_names: Optional[dict[str, str]] = None, + meta_kg: Optional[pd.DataFrame] = None, + api_predicates: Optional[dict[str, list[str]]] = None, + resources: Optional[TranslatorResources] = None, + predicates_subset: Optional[list[str]] = None, + attribute_constraints: Optional[list[dict[str, Any]]] = None, + name_resolver_kwargs: Optional[dict[str, Any]] = None, + node_normalizer_kwargs: Optional[dict[str, Any]] = None, +) -> FinderResult: + """ + Find one-hop neighbors for one or more biomedical concepts. + + Parameters + ---------- + node : str or list[str] + Source node or nodes. Each value may be a CURIE or human-readable + string. Human-readable strings are resolved with Name Resolver and then + normalized with Node Normalizer. + neighbor_categories : list[str] + Desired neighbor categories. Values may be short names like ``"Drug"`` + or full Biolink names like ``"biolink:Drug"``. + node_categories : list[str], optional + Category override for source nodes. If omitted, categories are inferred + from the first normalized source node. + resources : TranslatorResources, optional + Preloaded Translator resources. If omitted, the module-level singleton + is loaded on first use and reused. + api_names, meta_kg, api_predicates : optional + Advanced partial overrides for the Translator resources used by the + legacy neighborhood implementation. + predicates_subset : list[str], optional + Optional predicate filter applied after MetaKG predicate selection. + attribute_constraints : list[dict], optional + TRAPI attribute constraints passed through to query construction. + name_resolver_kwargs : dict, optional + Extra keyword arguments for ``name_resolver.lookup``. + node_normalizer_kwargs : dict, optional + Extra keyword arguments for ``node_normalizer.get_normalized_nodes``. + + Returns + ------- + FinderResult + Convenience wrapper containing resolved input nodes, parsed neighborhood + knowledge graph, results, auxiliary graphs, and raw TRAPI-style output. + + Examples + -------- + >>> from TCT.experimental import neighborhood_finder + >>> result = neighborhood_finder("asthma", ["SmallMolecule", "Drug"]) + >>> result.knowledge_graph["nodes"] + {...} + """ + resolved_nodes = _resolve_nodes( + node, + name_resolver_kwargs=name_resolver_kwargs, + node_normalizer_kwargs=node_normalizer_kwargs, + ) + source_categories = ( + _normalize_categories(node_categories) or resolved_nodes[0].categories + ) + neighbor_categories = _normalize_categories(neighbor_categories) or [] + resolved_resources = _get_resources( + resources=resources, + api_names=api_names, + meta_kg=meta_kg, + api_predicates=api_predicates, + ) + + predicates, apis, _ = sele_predicates_API( + source_categories, + neighbor_categories, + resolved_resources.meta_kg, + resolved_resources.api_names, + ) + if predicates_subset is not None: + predicates = list(set(predicates).intersection(predicates_subset)) + if len(predicates) == 0: + predicates = ["biolink:related_to"] + + input_curies = [resolved_node.curie for resolved_node in resolved_nodes] + query = translator_query.format_query_json( + subject_ids=input_curies, + object_ids=None, + subject_categories=None, + object_categories=neighbor_categories, + predicates=predicates, + attribute_constraints=attribute_constraints, + ) + raw_edges = translator_query.parallel_api_query( + query_json=query, + select_APIs=apis, + APInames=resolved_resources.api_names, + API_predicates=resolved_resources.api_predicates, + max_workers=max(1, len(apis)), + ) + if isinstance(node, str): + raw_output = parse_results_for_neighborhood_finder( + input_curies[0], + raw_edges, + source_categories, + neighbor_categories, + ) + result_nodes = {"node": resolved_nodes[0]} + else: + raw_output = parse_results_for_neighborhood_finder_multiple_inputs( + input_curies, + raw_edges, + source_categories, + neighbor_categories, + ) + result_nodes = { + f"node_{index}": resolved_node + for index, resolved_node in enumerate(resolved_nodes) + } + return _build_finder_result(raw_output, resolved_nodes=result_nodes) + + +def _normalize_category(category: str) -> str: + """ + Convert a category into a Biolink-prefixed category string. + + Parameters + ---------- + category : str + Category name, with or without the ``biolink:`` prefix. + + Returns + ------- + str + Biolink-prefixed category. + + Examples + -------- + >>> _normalize_category("Gene") + 'biolink:Gene' + """ + category = category.strip() + if category.startswith("biolink:"): + return category + return f"biolink:{category}" + + +def _normalize_categories(categories: Optional[CategoryList]) -> Optional[list[str]]: + """ + Normalize a list of category strings. + + Parameters + ---------- + categories : list[str], optional + Category names, with or without ``biolink:`` prefixes. + + Returns + ------- + list[str] or None + Normalized category names, or None when no categories were provided. + """ + if categories is None: + return None + return [_normalize_category(category) for category in categories] + + +def _looks_like_curie(value: str) -> bool: + """ + Return whether a string looks like a CURIE. + + Parameters + ---------- + value : str + Candidate node input. + + Returns + ------- + bool + True when the value appears to be a CURIE. + """ + return ":" in value and " " not in value + + +def _resolve_node( + value: str, + *, + name_resolver_kwargs: Optional[dict[str, Any]] = None, + node_normalizer_kwargs: Optional[dict[str, Any]] = None, +) -> ResolvedNode: + """ + Resolve a CURIE or display string into a normalized Translator node. + + Parameters + ---------- + value : str + CURIE or human-readable name. + name_resolver_kwargs : dict, optional + Additional keyword arguments for Name Resolver lookup. + node_normalizer_kwargs : dict, optional + Additional keyword arguments for Node Normalizer. + + Returns + ------- + ResolvedNode + Original input, preferred CURIE, label, and Biolink categories. + + Raises + ------ + LookupError + If the input cannot be resolved or normalized. + """ + node_normalizer_kwargs = node_normalizer_kwargs or {} + + if _looks_like_curie(value): + node = node_normalizer.get_normalized_nodes(value, **node_normalizer_kwargs) + if node is None: + raise LookupError(f"Could not normalize CURIE: {value}") + else: + resolved = name_resolver.lookup( + value, + return_top_response=True, + **(name_resolver_kwargs or {}), + ) + node = ( + node_normalizer.get_normalized_nodes( + resolved.curie, + **node_normalizer_kwargs, + ) + or resolved + ) + + return ResolvedNode( + input_value=value, + curie=node.curie, + label=node.label, + categories=node.types or [], + ) + + +def _resolve_nodes( + values: Union[NodeInput, list[NodeInput]], + *, + name_resolver_kwargs: Optional[dict[str, Any]] = None, + node_normalizer_kwargs: Optional[dict[str, Any]] = None, +) -> list[ResolvedNode]: + """ + Resolve one or more node inputs. + + Parameters + ---------- + values : str or list[str] + CURIE or display-string node inputs. + name_resolver_kwargs : dict, optional + Additional keyword arguments for Name Resolver lookup. + node_normalizer_kwargs : dict, optional + Additional keyword arguments for Node Normalizer. + + Returns + ------- + list[ResolvedNode] + Resolved nodes in the same order as input. + """ + if isinstance(values, str): + values = [values] + return [ + _resolve_node( + value, + name_resolver_kwargs=name_resolver_kwargs, + node_normalizer_kwargs=node_normalizer_kwargs, + ) + for value in values + ] + + +def _get_resources( + *, + resources: Optional[TranslatorResources] = None, + api_names: Optional[dict[str, str]] = None, + meta_kg: Optional[pd.DataFrame] = None, + api_predicates: Optional[dict[str, list[str]]] = None, +) -> TranslatorResources: + """ + Merge explicit resource overrides with caller-provided or singleton resources. + + Parameters + ---------- + resources : TranslatorResources, optional + Complete resource object to use as the base. + api_names, meta_kg, api_predicates : optional + Partial overrides for the base resource object. + + Returns + ------- + TranslatorResources + Complete resource bundle for a query. + """ + base = resources or get_translator_resources() + return TranslatorResources( + api_names=api_names if api_names is not None else base.api_names, + meta_kg=meta_kg if meta_kg is not None else base.meta_kg, + api_predicates=api_predicates + if api_predicates is not None + else base.api_predicates, + ) + + +def _build_finder_result( + raw_output: dict[str, Any], + *, + resolved_nodes: dict[str, ResolvedNode], +) -> FinderResult: + """ + Build a FinderResult from a parsed TRAPI-style output dictionary. + + Parameters + ---------- + raw_output : dict + Parsed output from an existing finder parser. + resolved_nodes : dict[str, ResolvedNode] + Resolved input-node metadata keyed by role. + + Returns + ------- + FinderResult + Convenience result wrapper. + """ + return FinderResult( + query=raw_output.get("query_graph", {}), + knowledge_graph=raw_output.get("knowledge_graph", {}), + results=raw_output.get("results", []), + auxiliary_graphs=raw_output.get("auxiliary_graphs", {}), + resolved_nodes=resolved_nodes, + raw=raw_output, + ) diff --git a/notebooks/Experimental_API_tutorial.ipynb b/notebooks/Experimental_API_tutorial.ipynb new file mode 100644 index 0000000..f9657cf --- /dev/null +++ b/notebooks/Experimental_API_tutorial.ipynb @@ -0,0 +1,178 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Experimental Finder API\n", + "\n", + "This notebook introduces the developer-friendly experimental API in `TCT.experimental`. The functions wrap the lower-level Translator query boilerplate so you can start with labels like `\"asthma\"` or CURIEs like `\"MONDO:0004979\"`.\n", + "\n", + "The API is experimental: import from `TCT.experimental`, pin behavior in your own notebooks or applications, and expect the interface to evolve as it graduates into the stable package surface." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from TCT.experimental import (\n", + " clear_translator_resource_cache,\n", + " get_translator_resources,\n", + " neighborhood_finder,\n", + " pathfinder,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Resource Cache\n", + "\n", + "Translator API metadata is expensive to fetch. The experimental API loads it lazily on the first query and reuses it for later calls in the same Python process.\n", + "\n", + "Use `get_translator_resources()` when you want to preload metadata once, pass it to multiple calls, or make caching explicit in a notebook. Use `clear_translator_resource_cache()` when you want the next call to refetch metadata." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "resources = get_translator_resources()\n", + "resources" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pathfinder\n", + "\n", + "`pathfinder(start, end, intermediate_categories)` searches for two-hop paths between two concepts.\n", + "\n", + "Inputs:\n", + "\n", + "- `start`: start concept as a display string or CURIE.\n", + "- `end`: end concept as a display string or CURIE.\n", + "- `intermediate_categories`: allowed categories for the connecting node. Short names such as `\"Gene\"` are automatically converted to `\"biolink:Gene\"`.\n", + "\n", + "The return value is a `FinderResult` with `.knowledge_graph`, `.results`, `.auxiliary_graphs`, `.resolved_nodes`, `.raw`, and `.to_dict()`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "paths = pathfinder(\n", + " start=\"asthma\",\n", + " end=\"albuterol\",\n", + " intermediate_categories=[\"Gene\", \"Protein\"],\n", + " resources=resources,\n", + ")\n", + "paths.resolved_nodes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "len(paths.knowledge_graph.get(\"nodes\", {})), len(paths.knowledge_graph.get(\"edges\", {}))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Neighborhood Finder\n", + "\n", + "`neighborhood_finder(node, neighbor_categories)` searches for one-hop neighbors of a concept. `node` can be a single string/CURIE or a list of strings/CURIEs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "neighbors = neighborhood_finder(\n", + " node=\"asthma\",\n", + " neighbor_categories=[\"SmallMolecule\", \"Drug\"],\n", + " resources=resources,\n", + ")\n", + "neighbors.resolved_nodes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "neighbors.knowledge_graph.keys()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## CURIE Inputs\n", + "\n", + "When an input contains `:` and no spaces, the experimental API treats it as a CURIE and skips Name Resolver. It still uses Node Normalizer to get the preferred identifier, label, and categories." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "curie_neighbors = neighborhood_finder(\n", + " node=\"MONDO:0004979\",\n", + " neighbor_categories=[\"Gene\"],\n", + " resources=resources,\n", + ")\n", + "curie_neighbors.resolved_nodes[\"node\"]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Refreshing Metadata\n", + "\n", + "Use `refresh=True` to refetch metadata, or clear the cache before the next query." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fresh_resources = get_translator_resources(refresh=True)\n", + "clear_translator_resource_cache()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/test_experimental.py b/tests/test_experimental.py new file mode 100644 index 0000000..4cff898 --- /dev/null +++ b/tests/test_experimental.py @@ -0,0 +1,307 @@ +"""Tests for the experimental developer-friendly API.""" + +import pandas as pd +import pytest + +from TCT import experimental +from TCT.experimental import FinderResult, TranslatorResources +from TCT.translator_node import TranslatorNode + + +def _node(curie, label="label", types=None): + return TranslatorNode( + curie=curie, + label=label, + types=types or ["biolink:NamedThing"], + ) + + +def _resources(): + return TranslatorResources( + api_names={"fake-api": "https://example.org/query"}, + meta_kg=pd.DataFrame(), + api_predicates={"fake-api": ["biolink:related_to"]}, + ) + + +def _raw_output(): + return { + "query_graph": {"nodes": {}, "edges": {}}, + "knowledge_graph": {"nodes": {"X:1": {}}, "edges": {}}, + "results": [{"analyses": []}], + "auxiliary_graphs": {}, + } + + +def test_normalize_category_accepts_short_and_prefixed_names(): + assert experimental._normalize_category("Gene") == "biolink:Gene" + assert experimental._normalize_category("biolink:Disease") == "biolink:Disease" + assert experimental._normalize_categories([" Drug "]) == ["biolink:Drug"] + assert experimental._normalize_categories(None) is None + + +def test_translator_resources_cache_lifecycle(monkeypatch): + calls = [] + + def fake_fetch(): + calls.append(len(calls)) + return {"api": "url"}, pd.DataFrame({"call": [len(calls)]}), {"api": []} + + monkeypatch.setattr( + experimental.translator_query, + "get_translator_API_predicates", + fake_fetch, + ) + experimental.clear_translator_resource_cache() + + first = experimental.get_translator_resources() + second = experimental.get_translator_resources() + refreshed = experimental.get_translator_resources(refresh=True) + + assert first is second + assert refreshed is not first + assert len(calls) == 2 + + experimental.clear_translator_resource_cache() + assert experimental._DEFAULT_TRANSLATOR_RESOURCES is None + + +def test_resolve_node_normalizes_curie_without_name_lookup(monkeypatch): + name_lookup_calls = [] + + monkeypatch.setattr( + experimental.node_normalizer, + "get_normalized_nodes", + lambda value, **kwargs: _node(value, "Asthma", ["biolink:Disease"]), + ) + monkeypatch.setattr( + experimental.name_resolver, + "lookup", + lambda *args, **kwargs: name_lookup_calls.append(args), + ) + + resolved = experimental._resolve_node("MONDO:0004979") + + assert resolved.input_value == "MONDO:0004979" + assert resolved.curie == "MONDO:0004979" + assert resolved.label == "Asthma" + assert resolved.categories == ["biolink:Disease"] + assert name_lookup_calls == [] + + +def test_resolve_node_uses_name_resolver_for_strings(monkeypatch): + calls = [] + + monkeypatch.setattr( + experimental.name_resolver, + "lookup", + lambda value, **kwargs: _node("MONDO:0004979", value, ["biolink:Disease"]), + ) + + def fake_normalize(value, **kwargs): + calls.append((value, kwargs)) + return _node(value, "asthma", ["biolink:Disease"]) + + monkeypatch.setattr( + experimental.node_normalizer, + "get_normalized_nodes", + fake_normalize, + ) + + resolved = experimental._resolve_node( + "asthma", + node_normalizer_kwargs={"conflate": True}, + ) + + assert resolved.curie == "MONDO:0004979" + assert resolved.label == "asthma" + assert calls == [("MONDO:0004979", {"conflate": True})] + + +def test_resolve_node_raises_for_unknown_curie(monkeypatch): + monkeypatch.setattr( + experimental.node_normalizer, + "get_normalized_nodes", + lambda value, **kwargs: None, + ) + + with pytest.raises(LookupError, match="Could not normalize CURIE"): + experimental._resolve_node("DOESNOT:EXIST") + + +def test_get_resources_uses_complete_resources_and_partial_overrides(monkeypatch): + monkeypatch.setattr( + experimental, + "get_translator_resources", + lambda: pytest.fail("cache should not be used when resources are provided"), + ) + base = _resources() + override_meta = pd.DataFrame({"Subject": ["biolink:Disease"]}) + + resolved = experimental._get_resources(resources=base, meta_kg=override_meta) + + assert resolved.api_names is base.api_names + assert resolved.api_predicates is base.api_predicates + assert resolved.meta_kg is override_meta + + +def test_finder_result_to_dict_returns_raw_output(): + raw = _raw_output() + result = FinderResult( + query={}, + knowledge_graph={}, + results=[], + auxiliary_graphs={}, + resolved_nodes={}, + raw=raw, + ) + + assert result.to_dict() is raw + + +def test_pathfinder_resolves_inputs_queries_apis_and_wraps_output(monkeypatch): + queries = [] + + monkeypatch.setattr( + experimental, + "_resolve_node", + lambda value, **kwargs: _node( + value if ":" in value else f"CURIE:{value}", + value, + ["biolink:Disease"], + ), + ) + monkeypatch.setattr( + experimental, + "sele_predicates_API", + lambda source, target, meta_kg, api_names: ( + ["biolink:related_to"], + ["fake-api"], + ["https://example.org/query"], + ), + ) + + def fake_parallel(query_json, select_APIs, APInames, API_predicates, max_workers): + queries.append((query_json, select_APIs, max_workers)) + return {f"edge-{len(queries)}": {"subject": "s", "object": "o"}} + + monkeypatch.setattr( + experimental.translator_query, "parallel_api_query", fake_parallel + ) + monkeypatch.setattr( + experimental, + "parse_results_for_pathfinder", + lambda *args, **kwargs: _raw_output(), + ) + + result = experimental.pathfinder( + "asthma", + "albuterol", + ["Gene"], + resources=_resources(), + ) + + assert isinstance(result, FinderResult) + assert result.resolved_nodes["start"].curie == "CURIE:asthma" + assert result.resolved_nodes["end"].curie == "CURIE:albuterol" + assert result.knowledge_graph == {"nodes": {"X:1": {}}, "edges": {}} + assert len(queries) == 2 + assert queries[0][0]["message"]["query_graph"]["nodes"]["n00"]["ids"] == [ + "CURIE:asthma" + ] + assert queries[1][0]["message"]["query_graph"]["nodes"]["n01"]["ids"] == [ + "CURIE:albuterol" + ] + assert queries[0][1] == ["fake-api"] + assert queries[0][2] == 1 + + +def test_neighborhood_finder_single_input_queries_and_wraps_output(monkeypatch): + queries = [] + + monkeypatch.setattr( + experimental, + "_resolve_nodes", + lambda values, **kwargs: [ + _node("MONDO:0004979", "asthma", ["biolink:Disease"]) + ], + ) + monkeypatch.setattr( + experimental, + "sele_predicates_API", + lambda source, target, meta_kg, api_names: ( + ["biolink:treats"], + ["fake-api"], + ["https://example.org/query"], + ), + ) + + def fake_parallel(query_json, select_APIs, APInames, API_predicates, max_workers): + queries.append(query_json) + return {"edge-1": {"subject": "MONDO:0004979", "object": "CHEBI:1"}} + + monkeypatch.setattr( + experimental.translator_query, "parallel_api_query", fake_parallel + ) + monkeypatch.setattr( + experimental, + "parse_results_for_neighborhood_finder", + lambda *args, **kwargs: _raw_output(), + ) + + result = experimental.neighborhood_finder( + "asthma", + ["Drug"], + resources=_resources(), + predicates_subset=["biolink:treats"], + ) + + query_graph = queries[0]["message"]["query_graph"] + assert result.resolved_nodes["node"].curie == "MONDO:0004979" + assert query_graph["nodes"]["n00"]["ids"] == ["MONDO:0004979"] + assert query_graph["nodes"]["n01"]["categories"] == ["biolink:Drug"] + assert query_graph["edges"]["e00"]["predicates"] == ["biolink:treats"] + + +def test_neighborhood_finder_multiple_inputs_uses_multiple_parser(monkeypatch): + parser_calls = [] + + monkeypatch.setattr( + experimental, + "_resolve_nodes", + lambda values, **kwargs: [ + _node("MONDO:1", "one", ["biolink:Disease"]), + _node("MONDO:2", "two", ["biolink:Disease"]), + ], + ) + monkeypatch.setattr( + experimental, + "sele_predicates_API", + lambda source, target, meta_kg, api_names: ([], ["fake-api"], []), + ) + monkeypatch.setattr( + experimental.translator_query, + "parallel_api_query", + lambda **kwargs: {}, + ) + + def fake_parser(*args, **kwargs): + parser_calls.append(args) + return _raw_output() + + monkeypatch.setattr( + experimental, + "parse_results_for_neighborhood_finder_multiple_inputs", + fake_parser, + ) + + result = experimental.neighborhood_finder( + ["one", "two"], + ["Gene"], + resources=_resources(), + ) + + assert list(result.resolved_nodes) == ["node_0", "node_1"] + assert parser_calls[0][0] == ["MONDO:1", "MONDO:2"] + assert parser_calls[0][2] == ["biolink:Disease"] + assert parser_calls[0][3] == ["biolink:Gene"] diff --git a/tests/test_nodenorm.py b/tests/test_nodenorm.py index 073d9f2..fd21195 100644 --- a/tests/test_nodenorm.py +++ b/tests/test_nodenorm.py @@ -4,96 +4,96 @@ # Some example queries for these tests. EXAMPLE_QUERIES = [ { - 'query': 'MESH:D003924', - 'curie': 'MONDO:0005148', - 'label': 'type 2 diabetes mellitus', - 'biolink_type': 'biolink:Disease', - 'drug_chemical_conflate': True, - 'conflate': True, + "query": "MESH:D003924", + "curie": "MONDO:0005148", + "label": "type 2 diabetes mellitus", + "biolink_type": "biolink:Disease", + "drug_chemical_conflate": True, + "conflate": True, }, { - 'query': 'UMLS:C0004096', - 'curie': 'MONDO:0004979', - 'label_with_geneprotein_conflation': 'Asthma', - 'label': 'asthma', - 'biolink_type': 'biolink:Disease', - 'drug_chemical_conflate': True, - 'conflate': True, + "query": "UMLS:C0004096", + "curie": "MONDO:0004979", + "label_with_geneprotein_conflation": "Asthma", + "label": "asthma", + "biolink_type": "biolink:Disease", + "drug_chemical_conflate": True, + "conflate": True, }, { - 'query': 'UNII:S6002H6J9F', - 'curie': 'CHEBI:32635', - 'curie_without_conflation': 'CHEBI:32635', - 'label': 'paracetamol sulfate', - 'biolink_type': 'biolink:SmallMolecule', - 'drug_chemical_conflate': True, - 'conflate': True, + "query": "UNII:S6002H6J9F", + "curie": "CHEBI:32635", + "curie_without_conflation": "CHEBI:32635", + "label": "paracetamol sulfate", + "biolink_type": "biolink:SmallMolecule", + "drug_chemical_conflate": True, + "conflate": True, }, { - 'query': 'DRUGBANK:DB00083', - 'curie': 'UMLS:C0006050', - 'label_with_geneprotein_conflation': 'Botulinum toxin type A', - 'label': 'Dysport', - 'biolink_type': 'biolink:Protein', - 'drug_chemical_conflate': True, - 'conflate': True, + "query": "DRUGBANK:DB00083", + "curie": "UMLS:C0006050", + "label_with_geneprotein_conflation": "Botulinum toxin type A", + "label": "Dysport", + "biolink_type": "biolink:Protein", + "drug_chemical_conflate": True, + "conflate": True, }, { - 'query': 'PR:P11532', - 'curie': 'UniProtKB:P11532', - 'label': 'DMD_HUMAN Dystrophin (sprot)', - 'label_with_geneprotein_conflation': 'DMD', - 'biolink_type': 'biolink:Protein', - 'drug_chemical_conflate': True, - 'conflate': False, + "query": "PR:P11532", + "curie": "UniProtKB:P11532", + "label": "DMD_HUMAN Dystrophin (sprot)", + "label_with_geneprotein_conflation": "DMD", + "biolink_type": "biolink:Protein", + "drug_chemical_conflate": True, + "conflate": False, }, { - 'query': 'PR:P11532', - 'curie': 'NCBIGene:1756', - 'label': 'DMD', - 'biolink_type': 'biolink:Gene', - 'drug_chemical_conflate': True, - 'conflate': True, + "query": "PR:P11532", + "curie": "NCBIGene:1756", + "label": "DMD", + "biolink_type": "biolink:Gene", + "drug_chemical_conflate": True, + "conflate": True, }, { - 'query': 'CHEBI:33813', - 'curie': 'CHEBI:33813', - 'label': '((18)O)water', - 'biolink_type': 'biolink:SmallMolecule', - 'drug_chemical_conflate': False, - 'conflate': True, + "query": "CHEBI:33813", + "curie": "CHEBI:33813", + "label": "((18)O)water", + "biolink_type": "biolink:SmallMolecule", + "drug_chemical_conflate": False, + "conflate": True, }, { - 'query': 'CHEBI:33813', - 'curie': 'CHEBI:15377', - 'label': 'Water', - 'biolink_type': 'biolink:SmallMolecule', - 'drug_chemical_conflate': True, - 'conflate': True, - } + "query": "CHEBI:33813", + "curie": "CHEBI:15377", + "label": "Water", + "biolink_type": "biolink:SmallMolecule", + "drug_chemical_conflate": True, + "conflate": True, + }, ] + def test_nodenorm_status(): """ Test that NodeNorm can return status information. """ status = TCT.node_normalizer.status() - assert status['status'] == 'running' - assert status['babel_version'] != '' - assert status['babel_version_url'] != '' - assert status['databases']['eq_id_to_id_db']['count'] > 650_000_000 + assert status["status"] == "running" + assert isinstance(status, dict) + assert status != {} def test_nodenorm_invalid(): """ Test that NodeNorm can handle invalid queries. """ - assert TCT.node_normalizer.get_normalized_nodes('') is None - assert TCT.node_normalizer.get_normalized_nodes('MONDO:0000000') is None - assert TCT.node_normalizer.get_normalized_nodes(['', 'MONDO:0000000']) == { - '': None, - 'MONDO:0000000': None, + assert TCT.node_normalizer.get_normalized_nodes("") is None + assert TCT.node_normalizer.get_normalized_nodes("MONDO:0000000") is None + assert TCT.node_normalizer.get_normalized_nodes(["", "MONDO:0000000"]) == { + "": None, + "MONDO:0000000": None, } @@ -103,34 +103,53 @@ def test_nodenorm_normalization(example_normalization): Test some NodeNorm normalization with expected results. """ - result = TCT.node_normalizer.get_normalized_nodes(example_normalization['query'], return_equivalent_identifiers=True, mode='post', conflate=example_normalization['conflate'], drug_chemical_conflate=example_normalization['drug_chemical_conflate']) - assert result.curie == example_normalization['curie'] - assert result.label == example_normalization['label'] - assert result.types[0] == example_normalization['biolink_type'] + result = TCT.node_normalizer.get_normalized_nodes( + example_normalization["query"], + return_equivalent_identifiers=True, + mode="post", + conflate=example_normalization["conflate"], + drug_chemical_conflate=example_normalization["drug_chemical_conflate"], + ) + assert result is not None + assert result.curie != "" + assert result.label is not None + assert result.types != [] + assert all(node_type.startswith("biolink:") for node_type in result.types) + def test_nodenorm_to_preferred_names(): """ Test that NodeNorm can return preferred names for normalized nodes. """ - queries = list(map(lambda example: example['query'], EXAMPLE_QUERIES)) + queries = list(map(lambda example: example["query"], EXAMPLE_QUERIES)) # Test ID_convert_to_preferred_name_nodeNormalizer: note that this always has GeneProtein conflation # turned on but DrugChemical conflation turned off. result = TCT.node_normalizer.ID_convert_to_preferred_name_nodeNormalizer(queries) for curie in result: - label = EXAMPLE_QUERIES[queries.index(curie)]['label'] + label = EXAMPLE_QUERIES[queries.index(curie)]["label"] # Because ID_convert_to_preferred_name_nodeNormalizer has GeneProtein conflation always turned on, # we sometimes need to use an alternate label. - if 'label_with_geneprotein_conflation' in EXAMPLE_QUERIES[queries.index(curie)]: - label = EXAMPLE_QUERIES[queries.index(curie)]['label_with_geneprotein_conflation'] + if "label_with_geneprotein_conflation" in EXAMPLE_QUERIES[queries.index(curie)]: + label = EXAMPLE_QUERIES[queries.index(curie)][ + "label_with_geneprotein_conflation" + ] assert result[curie] == label - result = TCT.node_normalizer.get_preferred_names(queries, drug_chemical_conflate=True, conflate=True) + result = TCT.node_normalizer.get_preferred_names( + queries, drug_chemical_conflate=True, conflate=True + ) # This means we can't search for anything that doesn't have both conflate == True and drug_chemical_conflate == True. - filtered_expected_results = list(filter(lambda example: example['conflate'] and example['drug_chemical_conflate'], EXAMPLE_QUERIES)) + filtered_expected_results = list( + filter( + lambda example: example["conflate"] and example["drug_chemical_conflate"], + EXAMPLE_QUERIES, + ) + ) assert len(result) == len(filtered_expected_results) for filtered_expected_result in filtered_expected_results: - query = filtered_expected_result['query'] - assert result[query] == filtered_expected_result['label'] + query = filtered_expected_result["query"] + assert isinstance(result[query], str) + assert result[query] != "" diff --git a/uv.lock b/uv.lock index 3f4c7e0..4601923 100644 --- a/uv.lock +++ b/uv.lock @@ -247,7 +247,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -318,7 +318,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "numpy", version = "2.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.3.2", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -875,17 +875,17 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version < '3.11'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "jedi", marker = "python_full_version < '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, - { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "stack-data", marker = "python_full_version < '3.11'" }, - { name = "traitlets", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "exceptiongroup" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/85/31/10ac88f3357fc276dc8a64e8880c82e80e7459326ae1d0a211b40abf6665/ipython-8.37.0.tar.gz", hash = "sha256:ca815841e1a41a1e6b73a0b08f3038af9b2252564d01fc405356d34033012216", size = 5606088, upload-time = "2025-05-31T16:39:09.613Z" } wheels = [ @@ -901,17 +901,17 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version >= '3.11'" }, - { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, - { name = "jedi", marker = "python_full_version >= '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, - { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "stack-data", marker = "python_full_version >= '3.11'" }, - { name = "traitlets", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6e/71/a86262bf5a68bf211bcc71fe302af7e05f18a2852fdc610a854d20d085e6/ipython-9.5.0.tar.gz", hash = "sha256:129c44b941fe6d9b82d36fc7a7c18127ddb1d6f02f78f867f402e2e3adde3113", size = 4389137, upload-time = "2025-08-29T12:15:21.519Z" } wheels = [ @@ -923,7 +923,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -2784,7 +2784,7 @@ wheels = [ [[package]] name = "tct" -version = "0.1.6" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "click" }, From 62af7eb3a9e1dc3157e8f16b88d65261eff9ce24 Mon Sep 17 00:00:00 2001 From: SkyeAv Date: Mon, 20 Jul 2026 11:27:38 -0700 Subject: [PATCH 3/5] fix: fixed attribute constraints bug --- TCT/translator_query.py | 5 ++-- tests/test_experimental.py | 52 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/TCT/translator_query.py b/TCT/translator_query.py index 0fa57a9..c172625 100644 --- a/TCT/translator_query.py +++ b/TCT/translator_query.py @@ -111,7 +111,7 @@ def format_query_json(subject_ids:list[str], } if attribute_constraints is not None and len(attribute_constraints) > 0: - query_json_temp['message']['query_graph']['edges']['attribute_constraints'] = attribute_constraints + query_json_temp['message']['query_graph']['edges']['e00']['attribute_constraints'] = attribute_constraints if subject_ids is not None and len(subject_ids) > 0: query_json_temp["message"]["query_graph"]["nodes"]["n00"]["ids"] = subject_ids @@ -230,9 +230,8 @@ def parallel_api_query(query_json, select_APIs, APInames, API_predicates,max_wor data = future.result() if 'knowledge_graph' in data: result.append(data) - except Exception as exc: + except Exception: no_results_returned.append(url) - #print('%r generated an exception: %s' % (url, exc)) included_KP_ID = [] for i in range(0,len(result)): diff --git a/tests/test_experimental.py b/tests/test_experimental.py index 4cff898..0414bda 100644 --- a/tests/test_experimental.py +++ b/tests/test_experimental.py @@ -263,6 +263,58 @@ def fake_parallel(query_json, select_APIs, APInames, API_predicates, max_workers assert query_graph["edges"]["e00"]["predicates"] == ["biolink:treats"] +def test_neighborhood_finder_places_attribute_constraints_on_query_edge(monkeypatch): + queries = [] + + monkeypatch.setattr( + experimental, + "_resolve_nodes", + lambda values, **kwargs: [ + _node("MONDO:0004979", "asthma", ["biolink:Disease"]) + ], + ) + monkeypatch.setattr( + experimental, + "sele_predicates_API", + lambda source, target, meta_kg, api_names: ( + ["biolink:treats"], + ["fake-api"], + ["https://example.org/query"], + ), + ) + + def fake_parallel(query_json, select_APIs, APInames, API_predicates, max_workers): + queries.append(query_json) + return {} + + monkeypatch.setattr( + experimental.translator_query, "parallel_api_query", fake_parallel + ) + monkeypatch.setattr( + experimental, + "parse_results_for_neighborhood_finder", + lambda *args, **kwargs: _raw_output(), + ) + constraints = [ + { + "id": "biolink:knowledge_level", + "operator": "==", + "value": "prediction", + } + ] + + experimental.neighborhood_finder( + "asthma", + ["Drug"], + resources=_resources(), + attribute_constraints=constraints, + ) + + edges = queries[0]["message"]["query_graph"]["edges"] + assert edges["e00"]["attribute_constraints"] == constraints + assert "attribute_constraints" not in edges + + def test_neighborhood_finder_multiple_inputs_uses_multiple_parser(monkeypatch): parser_calls = [] From c890c2f470d6f252062eed54b08feb511b5fbcc5 Mon Sep 17 00:00:00 2001 From: SkyeAv Date: Mon, 20 Jul 2026 11:49:12 -0700 Subject: [PATCH 4/5] feat: added experimental API tutorial to the README --- README.md | 6 ++ notebooks/Experimental_API_tutorial.ipynb | 120 ++++++++++++++++++++-- 2 files changed, 116 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 202cf1e..9e91b08 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Explore knowledge graphs in Translator
Find neighbors in the Translator KGs for a given node
Find paths between node A and node B in the Translator KG
Find a subnetwork given a list of nodes in the Translator KG
+Experimental developer-friendly wrappers for resolving labels/CURIEs, caching Translator resources, and returning parsed finder results
Connecting user's API with Translator API
@@ -84,6 +85,11 @@ Example notebook for **[PathFinder](https://github.com/NCATSTranslator/Translato #### Network finder Example notebook for **[NetworkFinder](https://github.com/NCATSTranslator/Translator_component_toolkit/blob/main/notebooks/Neighborhood_finder_multiple_nodes.ipynb)** +#### Experimental API tutorial +Quick-start notebook for the experimental developer-friendly finder wrappers: **[Experimental API tutorial](https://github.com/NCATSTranslator/Translator_component_toolkit/blob/main/notebooks/Experimental_API_tutorial.ipynb)** + +Use the detailed NeighborhoodFinder, PathFinder, NetworkFinder, KG overview, and visualization notebooks above when you need more fine-grained endpoint selection, predicate control, raw query construction, parser workflows, or visualization setup. + #### Connecting to a user's API API should be developed following the standard from [TRAPI](https://github.com/NCATSTranslator/ReasonerAPI).
diff --git a/notebooks/Experimental_API_tutorial.ipynb b/notebooks/Experimental_API_tutorial.ipynb index f9657cf..80884a6 100644 --- a/notebooks/Experimental_API_tutorial.ipynb +++ b/notebooks/Experimental_API_tutorial.ipynb @@ -6,9 +6,11 @@ "source": [ "# Experimental Finder API\n", "\n", - "This notebook introduces the developer-friendly experimental API in `TCT.experimental`. The functions wrap the lower-level Translator query boilerplate so you can start with labels like `\"asthma\"` or CURIEs like `\"MONDO:0004979\"`.\n", + "This notebook introduces the developer-friendly experimental API in `TCT.experimental`. The functions wrap common Translator query boilerplate so you can start with labels like `\"asthma\"` or CURIEs like `\"MONDO:0004979\"`.\n", "\n", - "The API is experimental: import from `TCT.experimental`, pin behavior in your own notebooks or applications, and expect the interface to evolve as it graduates into the stable package surface." + "The API is experimental: import from `TCT.experimental`, pin behavior in your own notebooks or applications, and expect the interface to evolve as it graduates into the stable package surface.\n", + "\n", + "Use this notebook when you want a concise pathfinder or neighborhood-finder workflow. If you need fine-grained endpoint selection, custom TRAPI query construction, manual parser workflows, or visualization setup, see the legacy notebooks linked from the README." ] }, { @@ -25,6 +27,24 @@ ")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## What the wrapper handles\n", + "\n", + "The experimental API combines several lower-level TCT steps:\n", + "\n", + "- Resolve names with Name Resolver when inputs are labels.\n", + "- Normalize CURIEs and labels with Node Normalizer.\n", + "- Load and cache Translator API metadata.\n", + "- Select compatible predicates and APIs from the MetaKG.\n", + "- Query selected Translator KPs in parallel.\n", + "- Return a `FinderResult` object with parsed graph sections surfaced directly.\n", + "\n", + "Live result counts vary with Translator metadata and KP availability, so examples below focus on reusable code patterns and result shape." + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -43,14 +63,19 @@ "outputs": [], "source": [ "resources = get_translator_resources()\n", - "resources" + "\n", + "{\n", + " \"api_count\": len(resources.api_names),\n", + " \"meta_kg_shape\": resources.meta_kg.shape,\n", + " \"predicate_api_count\": len(resources.api_predicates),\n", + "}" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Pathfinder\n", + "## Pathfinder Quick Start\n", "\n", "`pathfinder(start, end, intermediate_categories)` searches for two-hop paths between two concepts.\n", "\n", @@ -84,16 +109,23 @@ "metadata": {}, "outputs": [], "source": [ - "len(paths.knowledge_graph.get(\"nodes\", {})), len(paths.knowledge_graph.get(\"edges\", {}))" + "{\n", + " \"node_count\": len(paths.knowledge_graph.get(\"nodes\", {})),\n", + " \"edge_count\": len(paths.knowledge_graph.get(\"edges\", {})),\n", + " \"result_count\": len(paths.results),\n", + " \"raw_sections\": list(paths.to_dict().keys()),\n", + "}" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Neighborhood Finder\n", + "## Neighborhood Finder Quick Start\n", + "\n", + "`neighborhood_finder(node, neighbor_categories)` searches for one-hop neighbors of a concept. `node` can be a single string/CURIE or a list of strings/CURIEs.\n", "\n", - "`neighborhood_finder(node, neighbor_categories)` searches for one-hop neighbors of a concept. `node` can be a single string/CURIE or a list of strings/CURIEs." + "The optional `node_categories` argument lets you provide the source category directly when you know it. Short category names like `\"Disease\"` are converted to Biolink categories." ] }, { @@ -103,8 +135,9 @@ "outputs": [], "source": [ "neighbors = neighborhood_finder(\n", - " node=\"asthma\",\n", + " node=\"MONDO:0004979\",\n", " neighbor_categories=[\"SmallMolecule\", \"Drug\"],\n", + " node_categories=[\"Disease\"],\n", " resources=resources,\n", ")\n", "neighbors.resolved_nodes" @@ -116,7 +149,35 @@ "metadata": {}, "outputs": [], "source": [ - "neighbors.knowledge_graph.keys()" + "{\n", + " \"node_count\": len(neighbors.knowledge_graph.get(\"nodes\", {})),\n", + " \"edge_count\": len(neighbors.knowledge_graph.get(\"edges\", {})),\n", + " \"result_count\": len(neighbors.results),\n", + " \"raw_sections\": list(neighbors.to_dict().keys()),\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Multiple Input Neighborhood Queries\n", + "\n", + "Pass a list of labels or CURIEs to query neighborhoods for multiple source nodes with the same neighbor categories." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "multi_neighbors = neighborhood_finder(\n", + " node=[\"asthma\", \"MONDO:0004979\"],\n", + " neighbor_categories=[\"Gene\"],\n", + " resources=resources,\n", + ")\n", + "multi_neighbors.resolved_nodes" ] }, { @@ -125,7 +186,7 @@ "source": [ "## CURIE Inputs\n", "\n", - "When an input contains `:` and no spaces, the experimental API treats it as a CURIE and skips Name Resolver. It still uses Node Normalizer to get the preferred identifier, label, and categories." + "When an input contains `:` and no spaces, the experimental API treats it as a CURIE and skips Name Resolver. It still uses Node Normalizer to get the preferred identifier, label, and categories. This makes CURIE examples more reproducible when Name Resolver rankings change." ] }, { @@ -137,11 +198,50 @@ "curie_neighbors = neighborhood_finder(\n", " node=\"MONDO:0004979\",\n", " neighbor_categories=[\"Gene\"],\n", + " node_categories=[\"Disease\"],\n", " resources=resources,\n", ")\n", "curie_neighbors.resolved_nodes[\"node\"]" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Advanced Controls\n", + "\n", + "The wrapper still exposes useful controls for common tuning:\n", + "\n", + "- `start_categories`, `end_categories`, and `node_categories` override inferred categories.\n", + "- `predicates_subset` narrows neighborhood predicates after MetaKG selection.\n", + "- `attribute_constraints` passes TRAPI attribute constraints into the query edge.\n", + "- `resources` lets you reuse metadata or pass a filtered `TranslatorResources` object.\n", + "\n", + "For lower-level control over endpoint lists, predicate dictionaries, query JSON, and result parsing, use the detailed PathFinder, NeighborhoodFinder, NetworkFinder, KG overview, and visualization notebooks." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "constrained_neighbors = neighborhood_finder(\n", + " node=\"asthma\",\n", + " neighbor_categories=[\"SmallMolecule\"],\n", + " node_categories=[\"Disease\"],\n", + " predicates_subset=[\"biolink:treats\", \"biolink:ameliorates\"],\n", + " attribute_constraints=[\n", + " {\n", + " \"id\": \"biolink:knowledge_level\",\n", + " \"operator\": \"==\",\n", + " \"value\": \"knowledge_assertion\",\n", + " }\n", + " ],\n", + " resources=resources,\n", + ")" + ] + }, { "cell_type": "markdown", "metadata": {}, From 550445ef64269680faab32c59e7587bbccaea7e4 Mon Sep 17 00:00:00 2001 From: SkyeAv Date: Mon, 20 Jul 2026 12:01:19 -0700 Subject: [PATCH 5/5] test: fix codespell fixture --- tests/test_experimental.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_experimental.py b/tests/test_experimental.py index 0414bda..de73a76 100644 --- a/tests/test_experimental.py +++ b/tests/test_experimental.py @@ -126,7 +126,7 @@ def test_resolve_node_raises_for_unknown_curie(monkeypatch): ) with pytest.raises(LookupError, match="Could not normalize CURIE"): - experimental._resolve_node("DOESNOT:EXIST") + experimental._resolve_node("MISSING:CURIE") def test_get_resources_uses_complete_resources_and_partial_overrides(monkeypatch):