diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5ddb5c..d7b234d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,7 @@ jobs: - name: Run hermetic suite with coverage and skip reasons run: | uv sync --frozen --all-extras --dev + uv run --frozen --no-sync python -c "import duckdb; c = duckdb.connect(); c.install_extension('vss'); c.close()" export GROUNDWORKERS_REQUIRE_FORMAT_TESTS=1 echo "GROUNDWORKERS_REQUIRE_FORMAT_TESTS=1" >> "$GITHUB_ENV" COVERAGE_FILE="$RUNNER_TEMP/groundworkers.coverage" uv run pytest -q -ra \ @@ -90,3 +91,6 @@ jobs: uv venv "$RUNNER_TEMP/gw-wheel-extras" --python 3.12 uv pip install --python "$RUNNER_TEMP/gw-wheel-extras/bin/python" "$wheel[all_source,tui]" httpx "$RUNNER_TEMP/gw-wheel-extras/bin/python" "$GITHUB_WORKSPACE/tests/support/wheel_smoke.py" extras + uv venv "$RUNNER_TEMP/gw-wheel-duckdb" --python 3.12 + uv pip install --python "$RUNNER_TEMP/gw-wheel-duckdb/bin/python" "$wheel[duckdb]" + "$RUNNER_TEMP/gw-wheel-duckdb/bin/python" "$GITHUB_WORKSPACE/tests/support/wheel_smoke.py" duckdb diff --git a/config/groundworkers.duckdb.example.toml b/config/groundworkers.duckdb.example.toml new file mode 100644 index 0000000..1ec83ab --- /dev/null +++ b/config/groundworkers.duckdb.example.toml @@ -0,0 +1,39 @@ +# Serve two existing, cleanly closed DuckDB files. Groundworkers opens them read-only. +# Set the model, dimensions and prefixes from the asset manifest. + +[connections.datathon_cdm] +dialect = "duckdb" +database_name = "/abs/path/datathon_cdm.duckdb" + +[connections.datathon_emb] +dialect = "duckdb" +database_name = "/abs/path/datathon_emb.duckdb" + +[databases.cdm_db] +kind = "cdm" +connection = "datathon_cdm" + +[databases.emb_db] +kind = "generic" +connection = "datathon_emb" + +[providers.local_ollama] +provider = "ollama" +base_url = "http://localhost:11434" +api_key = "ollama" + +[models.snowflake] +provider = "local_ollama" +model = "snowflake-arctic-embed2:568m" +embeddings = true +embedding_dim = 1024 +query_prefix = "query: " + +[vector_stores.datathon] +backend_type = "duckdbvss" +database = "emb_db" + +[tools.groundworkers] +cdm_db = "cdm_db" +embedding_model_name = "snowflake" +vector_store_name = "datathon" diff --git a/docs/adapters/ancestry.md b/docs/adapters/ancestry.md new file mode 100644 index 0000000..7e2bbef --- /dev/null +++ b/docs/adapters/ancestry.md @@ -0,0 +1,71 @@ +# Stored ancestry and shared targets + +Groundworkers reads OMOP's `CONCEPT_ANCESTOR` closure table. Its `max_depth` is +the stored `min_levels_of_separation`, rather than a count of recursive +parent lookups. Distinct concepts can have separation zero, so zero depth can +include more than the input concept itself. + +## Find a shared standard ancestor + +Use `concept_shared_standard_ancestor` to find a shared representation for two +or more candidate concepts for the same source record. It does not combine +separate drug ingredients into a product or establish clinical equivalence. + +```python +result = context.concept_shared_standard_ancestor( + [first_concept_id, second_concept_id], + max_depth=5, + concept_class_ids=["Clinical Drug"], + vocabulary_ids=["RxNorm", "RxNorm Extension"], +) +``` + +The Python/plugin method returns the service payload. The MCP operation wraps +that payload in its typed `data` field; errors have `error=true`, a code and a +message, with no selected target. + +The operation requires 2–200 supplied positive integer IDs and at least two +distinct IDs. Boolean, string and fractional IDs are rejected. Class and +vocabulary filters use exact catalogue identifiers; null means unrestricted, +while empty lists, blank values and unknown identifiers are invalid. Asking +for RxNorm does not implicitly include RxNorm Extension. + +Groundworkers intersects the complete stored ancestry of all inputs, including +each input at distance zero, then filters to active standard targets in the same +domain and any requested classes/vocabularies. It minimizes the maximum stored +distance from the inputs. A unique nearest target is selected; equal nearest +targets remain ambiguous. Unknown input IDs are errors, and mixed input domains +produce an incompatible-input result. + +`limit` (1–50) and `offset` paginate displayed common candidates. They do not +affect selection: displaying one candidate cannot resolve a tie. The response +reports selection completeness, total candidates and display truncation +separately. A database failure returns an error rather than a partial selection. + +## Nearest ancestors and path evidence + +`concept_nearest_standard_ancestor` uses the same stored-distance semantics and +reports ambiguous nearest targets. Its text-query form discloses the rank-one +grounded seed and other seed candidates; it does not establish that the source +concept itself was unambiguous. + +Relationship paths are separate evidence from stored ancestry. An ancestor can +be valid even when a bounded relationship-path search finds no path. The result +reports that limitation rather than fabricating a path. A `Maps to` fallback is +labelled as mapping evidence, not ancestry. + +## Bounded named-relationship neighborhoods + +The Python/plugin `concept_relationship_neighborhood` capability follows an +explicit set of active outbound relationship IDs. It accepts up to 50 distinct +seed concepts, traverses at most three levels and bounds each seed's nodes and +edges independently. `max_nodes_per_seed` and `max_edges_per_seed` describe +those allowances; `returned_edge_count` counts the deduplicated edges actually +returned across all seeds. One dense seed does not consume another seed's +allowance. `truncated_seeds` identifies walks that reached a resource limit. + +The maximum supported union before overlap deduplication is 25,000 nodes and +125,000 edges (50 seeds, 500 nodes and 2,500 edges per seed). Prefer small seed +sets and limits for interactive use. Unknown seed concepts return `NOT_FOUND` +with the missing seed IDs. Missing metadata for reached non-seed concepts returns +`QUERY_ERROR` rather than a silently incomplete neighborhood. diff --git a/docs/adapters/omop_emb.md b/docs/adapters/omop_emb.md index 44d2163..2f8f135 100644 --- a/docs/adapters/omop_emb.md +++ b/docs/adapters/omop_emb.md @@ -20,9 +20,13 @@ The adapter does **not** resolve stack config itself. `build_application(...)` c |---|---| | `sqlitevec` | a `[vector_stores.*]` entry with `backend_type = "sqlitevec"` and a database reference | | `pgvector` | a `[vector_stores.*]` entry with `backend_type = "pgvector"` and a `database` reference | +| `duckdbvss` | a `[vector_stores.*]` entry with `backend_type = "duckdbvss"` and a separate prebuilt DuckDB database reference | FAISS remains a sidecar acceleration layer rather than a standalone primary backend. +See [database and vector-storage configuration](../usage/configuration.md#databases-and-vector-storage) +for connection dialects and file-serving requirements. + ## Operation modes ### Index lookup mode @@ -42,7 +46,13 @@ Requires `embedding_model_name` and `vector_store_name`, plus a configured embed ## Availability model -The underlying backend is built lazily on first use. If the backend cannot be opened, the adapter reports unavailability rather than failing application construction for callers that do not need embedding-backed operations. +Configuration is validated when constructing the application. A configured +DuckDB embedding file must already exist and be separate from the CDM file; +invalid file configuration stops startup. + +The underlying backend is built lazily on first use. Failures while opening a +validly configured backend are reported as embedding unavailability. Registered +models and stored vectors can be inspected without a live query encoder. Representative `index_status()` shape: diff --git a/docs/development/ancestry-mcp-design.md b/docs/development/ancestry-mcp-design.md deleted file mode 100644 index 392201b..0000000 --- a/docs/development/ancestry-mcp-design.md +++ /dev/null @@ -1,186 +0,0 @@ -# Ancestry MCP contract design - -Status: implemented in Groundworkers. The MCP tool, typed envelope, catalogue validation and canonical ancestor displays follow this contract; the temporary ancestry query remains isolated for future migration into omop-graph. Restart an existing MCP server and refresh client discovery before inference sessions. - -## Goals and ownership - -Expose complete stored OMOP ancestry consistently, make abstention understandable, and let an interactive mapping agent request an appropriate target class without imposing a drug policy on all callers. Preserve the temporary primitive's migration route into omop-graph. - -- `adapters/_omop_graph_ancestry.py`: portable SQL query only, as implemented. -- `OmopGraphAdapter`: query execution, normalized concept metadata and backend errors. -- `GraphService`: canonical stored-ancestry retrieval with stored distances. -- `MappingService`: common-set intersection, eligibility, constrained ranking and explicit ties. -- `MappingContext`: same service capability for plugins. -- MCP tools: validated inputs, typed response, concise help and resource limits. -- PBS plugin or interactive agent: interpretation of source ingredients, form, strength, route, release type and package; clinical acceptance and broad-match labels. - -Do not expose a low-level SQL/query-building tool. Do not introduce a second generic grounding tool for this feature. - -## Public MCP operation - -Register `concept_shared_standard_ancestor` in `tools/mapping_tools.py` under the existing mapping-service availability gate. This name agrees with the existing Python/plugin operation. - -Proposed signature (bounds must be in generated JSON Schema as well as runtime validation): - -```python -concept_shared_standard_ancestor( - concept_ids: list[positive_strict_int], # 2..200 supplied IDs; >=2 distinct - max_depth: int = 5, # 0..20, stored minimum separation - concept_class_ids: list[str] | None = None, - vocabulary_ids: list[str] | None = None, - limit: int = 10, # 1..50 displayed common candidates - offset: int = 0, # >=0, display pagination only -) -> SharedAncestorResponse -``` - -`positive_strict_int` denotes an Annotated/Pydantic strict integer constrained above zero, not a new wire type. Reject booleans, floats and string coercion. Deduplicate IDs in first-seen order. Enforce the supplied-list cap before deduplication; reject fewer than two distinct IDs. Do not silently truncate inputs. - -Omitted/null class or vocabulary constraints mean unrestricted. Empty lists and blank values are invalid. Trim and deduplicate filter values, validate them against the installed catalogue, and return INVALID_INPUT for unknown identifiers, naming the unrecognized values. A recognized vocabulary with no qualifying targets yields a scoped no-match. Exact vocabulary IDs are required; requesting RxNorm alone does not include RxNorm Extension. Request both when appropriate. - -Keep `max_depth` for naming consistency but document that it measures stored `min_levels_of_separation`, not recursive parent hops. Explicit zero is supported. Reject bounds violations rather than silently clamping them. Use one shared validator for Python, plugin and MCP paths so these paths do not disagree on filter semantics. The existing Python test that treats an empty constraint list as no-match must change to the explicit invalid-input contract. - -Suggested tool description: - -> Find common active standard OMOP ancestors of two or more known concept IDs. Use this to test whether alternative candidate concepts for the same source record converge on a shared representation. Optional class and vocabulary constraints apply before selection. Returns a unique nearest eligible ancestor or an explicit ambiguous/no-match result. Distances come from CONCEPT_ANCESTOR, including distinct zero-separation ancestors. A shared ancestor is a generalization; it does not establish equivalence or clinical mapping correctness. Do not use this operation to merge separate combination-drug ingredients into a product. - -Preserve the same-domain requirement; it must be stated in help and echoed in the result. Mixed source domains produce an incompatible-inputs outcome with the observed domain IDs. Do not discard disagreeing inputs to obtain a match. - -## Selection and completeness - -1. Validate and load every input concept; unknown IDs are NOT_FOUND, backend failure is an error. -2. Retrieve all stored ancestry rows satisfying max_depth, including distinct zero-separation rows. Add each seed itself at distance zero. Seed concepts may be nonstandard or inactive; eligible targets must be active standard concepts. -3. Intersect ancestry across all distinct inputs, then apply same-domain, class and vocabulary eligibility. Filtering ancestors must never remove an input from the intersection requirement. -4. Rank each eligible common ancestor by the maximum of its stored minimum distances from all inputs. Exactly one nearest target is selected. Equal-distance targets remain ambiguous; concept ID sorting controls display order only. -5. Compute the outcome and total counts using the complete eligible common set, then paginate display candidates. `limit=1` cannot turn a tie into selection. Offset cannot affect the chosen target. - -No silent query-row or node cap is permitted during selection. Enforce an input-size bound and the configured database statement timeout. If completion fails, return an execution error with no selected ancestor. `selection_complete` must never be true for a partial computation. A display page may be truncated while the selection calculation remains complete; keep these concepts distinct. - -## Typed MCP response - -Use a single object response with typed fields and typed `data`, retaining compatibility with the server's existing flat error dictionaries: - -```python -class SharedAncestorResponse(BaseModel): - error: bool = False - code: ErrorCode | None = None - message: str | None = None - data: SharedAncestorData | None = None -``` - -Validate the envelope: on success data is required and code/message are absent; on error code/message are required, data is null, and no selection is exposed. Existing guarded error dictionaries `{error: true, code, message}` must validate through this model. The output model should not permit arbitrary dictionaries for the ancestry payload. - -Important SDK constraint: a local FastMCP spike showed that annotating a return as `Success | Failure` automatically introduces `structuredContent.result`, while text content renders the unwrapped object. Use one object response so text and structured forms have the same documented shape. Add a contract test for this actual SDK behavior rather than assuming the return annotation alone fixes the wire contract. A second spike through GroundworkersMCPServer confirmed the single model works, but a guarded error dictionary omits null/default fields from text while structuredContent adds them. Normalize guarded results through the declared response model before returning to FastMCP so both content forms serialize the same payload. Ensure errors still pass through message scrubbing. - -Successful example (candidate/page rows shortened for readability): - -```json -{ - "error": false, - "code": null, - "message": null, - "data": { - "status": "selected", - "found": true, - "input_concept_ids": [40166038, 41038881], - "constraints": { - "concept_class_ids": ["Clinical Drug"], - "vocabulary_ids": ["RxNorm", "RxNorm Extension"], - "standard_only": true, - "active_only": true, - "same_domain": true - }, - "ancestry_source": "concept_ancestor", - "distance_metric": "min_levels_of_separation", - "max_depth": 5, - "include_self": true, - "selection_complete": true, - "selection_reason": "unique_nearest_within_constraints", - "selected_ancestor": { - "concept_id": 40166037, - "concept_name": "saxagliptin 2.5 MG Oral Tablet", - "vocabulary_id": "RxNorm", - "domain_id": "Drug", - "concept_class_id": "Clinical Drug", - "standard_concept": true, - "is_active": true, - "depth": 1 - }, - "selected_distances_by_input": {"40166038": 0, "41038881": 1}, - "common_ancestors": [{ - "concept_id": 40166037, - "concept_name": "saxagliptin 2.5 MG Oral Tablet", - "vocabulary_id": "RxNorm", - "domain_id": "Drug", - "concept_class_id": "Clinical Drug", - "standard_concept": true, - "is_active": true, - "depth": 1 - }], - "common_candidate_count": 1, - "nearest_candidate_count": 1, - "page": {"offset": 0, "limit": 10, "returned": 1, "has_more": false} - } -} -``` - -Each candidate uses the existing flags concept projection plus depth; the example abbreviates optional identity metadata. The selected concept must be included independently of pagination. Per-input distances for the selected result are bounded by the 200-input cap; alternatives remain compact. Preserve complete per-input evidence in replay artifacts when needed. - -`status` is one of: - -| Status | Meaning | found | selected_ancestor | -|---|---|---|---| -| selected | One unique nearest eligible ancestor | true | required | -| ambiguous | Multiple equally nearest eligible ancestors | false | null | -| no_match | No common eligible ancestor within the requested depth and constraints | false | null | -| incompatible_inputs | Inputs span different domains | false | null | - -Unknown concepts, invalid requests and backend failures use the error envelope, never no_match. Stable machine reasons distinguish the statuses; an agent should not need to infer ambiguity from candidate counts. `common_candidate_count` and `nearest_candidate_count` always refer to full computed sets. `found` remains an alias for selection success for compatibility; it does not mean that common ancestry exists. In ambiguity, common ancestry exists despite found=false. - -JSON Schema argument-validation failures may occur in the MCP SDK before the tool function runs. Treat these as native MCP tool errors (`isError=true`), distinct from the typed error envelope for requests that reached the service. Do not promise a typed payload for a protocol/schema error. Include both error routes in inference traces and wire-contract tests. - -The MCP serializer can adapt the legacy Python service keys. Preserve existing plugin keys such as `selected_ancestor` and `selection_reason`; add fields rather than breaking those consumers. Document any intentional service validation change. - -## Align existing tools - -| Tool/path | Required change | -|---|---| -| concept_ancestors | Use canonical stored ancestry, exclude self by default, retain distinct zero-separation rows, allow max_depth=0, return the distance metric and effective scope. Preserve the existing ancestors list key. | -| concept_mapping_context | Use the same ancestor data. Keep ancestor_limit as a result-count limit; add ancestor_max_depth=5 independently. When ancestors are requested, require ancestor_limit=1..50 and depth=0..20. Echo count/depth/truncation separately. | -| concept_candidate_bundle hierarchy preview | Use canonical stored ancestry so previews do not contradict direct lookups. Report preview truncation. | -| concept_nearest_standard_ancestor | Use stored distances and active standard targets. Abstain on equal nearest distances instead of taking the first row. Preserve selected_parent and found keys; add explicit status/reason. Preserve direct Maps-to fallback as a separate mapping route with its own reason. | - -The single-concept nearest tool accepts a query or concept_id. Ambiguity in selecting the source seed is a separate concern from ancestor ambiguity. For this release, retain and disclose the existing rank-1 seed policy and returned seed candidates; do not imply a unique source concept was established. A text query that selects a seed is not a deterministic complete clinical mapper. - -`path_to_parent` traverses relationships whereas ancestor eligibility comes from stored closure. Failure to construct a bounded relationship path must not invalidate the ancestor-table evidence or fabricate a path. Return an empty path with an explicit explanation when those representations differ. Maps-to fallback must also be labelled as mapping, not ancestry. - -Leave parent/child primitives and descendant traversal unchanged in this scoped release; document their separate semantics pending a dedicated descendant audit. The shared operation and read-only ancestor displays must be internally consistent. Document that ancestry includes zero-separation relations and does not prove interchangeability. - -## MCP registration and annotations - -Extend GroundworkersMCPServer.tool with optional keyword-only ToolAnnotations metadata and a result_model for typed output normalization; existing decorators remain valid. For the new tool, set result_model=SharedAncestorResponse; registration advertises that return type while the implementation returns an unvalidated dictionary for boundary validation. Resolve Annotated input aliases in the original tool namespace before FastMCP registration. After success or guarded error conversion, sanitize errors, validate through result_model and dump to JSON-compatible fields before FastMCP serialization. A malformed service result becomes a scrubbed INTERNAL error envelope, not a partial selection. This validation is opt-in for the new tool; do not silently change all existing tool payloads. Keep stored metadata alongside functions, expose it through describe_tools and forward it to FastMCP at run time. Mark the new operation readOnlyHint=true, destructiveHint=false, idempotentHint=true, openWorldHint=false. These are client hints, not authorization controls. Describe idempotence relative to an unchanged vocabulary snapshot. - -Typed return annotations must survive wraps and error guarding. Test native MCP input validation, output validation, structuredContent and textContent; direct server.call tests alone are insufficient. The new tool stays in the existing mapping-service registration gate and has no embedding/LLM requirement. - -## Implementation sequence and acceptance gates - -1. Add typed schemas/validators and normalize the shared service result. Add MCP registration and annotation support. Verify the actual tools/list schema and success/error wire shapes. -2. Route ancestor displays/previews through the canonical stored primitive. Separate mapping-context result limits from depth. Preserve old result keys and document the two intentional changes: newly visible zero-distance ancestors and nearest-tool tie abstention. -3. Extend documentation, plugin examples and registration tests. Retain the upstream migration guide and keep SQL/ancestry retrieval independent of MCP types. -4. Replay deterministic fixed-seed cases, then run interactive sessions after restarting the MCP server and refreshing the client's tool list. Do not claim inference readiness before these steps pass. - -Required regression scenarios: distinct zero-distance ancestor; explicit self; zero depth; mixed domains; inactive/nonstandard target; invalid and duplicated inputs; unknown filters; class-constrained tie resolution; empty constraint rejection; limit=1 preserving ambiguity; offset not changing selection; missing metadata/backend timeout returning an error; consistent ancestor presence across MCP tools; annotation and generated-schema assertions; split-vocabulary session routing. Compare constrained results with all 46 saved qualifying cases and preserve generic 29-selected/17-ambiguous behavior on that set. - -## Interactive inference evaluation - -Keep two distinct evaluations: - -- Fixed candidate IDs exercise tool behavior and whether the agent understands selected, ambiguous and scoped no-match. The saved pilot requests contain no expected target IDs; the separate oracle is for evaluation only. -- Source-record prompts exercise end-to-end retrieval, candidate selection, class choice and mapping acceptance. Use the original source fields and hide historical references, expected concepts and adjudication labels from the agent. - -Run the same model/settings/prompts against clearly versioned old/new toolsets. Save the advertised tool definitions, model/settings, source input, every tool argument/result, final target IDs, mapping grain and unresolved details. Record the vocabulary snapshot and Groundworkers source revision/hash. Restart the server and refresh client discovery between versions; stale tools/list metadata makes the comparison invalid. - -Require the final agent answer to distinguish exact product, broad Clinical Drug, ingredient-only, ambiguous and abstained outcomes. Score ingredient precision/recall and exact-set agreement alongside form/strength/route/release/package correctness, abstentions and unsupported acceptance. A selected shared ancestor is tool evidence, not a correctness label. Review whether agents inappropriately treat zero distance as equivalence, omit dissenting candidates, merge separate ingredients, or overstate class-constrained uniqueness. - -The existing 200-code cohort has already informed tuning. It remains useful for regression comparisons; an untouched sample is needed for a subsequent generalization claim. Interactive agent sessions also differ from the earlier vanilla rank-1 retrieval arm and must be labelled as a new workflow, with no implied apples-to-apples speed or clinical-accuracy ranking. - -For an initial pilot, use the five saved probes (10128C, 10413C, 10714X, 13406N, 8881K). Expect four generic selections and one generic cross-class tie; all five should select the previously audited target under the Clinical Drug constraint. Add negative/source-discordant cases before estimating performance. Repeated agent runs can reveal tool-use variability even though these graph operations are deterministic for a fixed vocabulary. diff --git a/docs/from-scratch.md b/docs/from-scratch.md index 5d82b7f..f9d47ff 100644 --- a/docs/from-scratch.md +++ b/docs/from-scratch.md @@ -25,10 +25,13 @@ Install Groundworkers with the setup console: uv pip install "groundworkers[tui]" ``` -If you plan to store embeddings in PostgreSQL, include its backend support: +Include the extra for the database backend you plan to use: ```bash +# PostgreSQL embeddings uv pip install "groundworkers[tui,embedding-pgvector]" +# DuckDB vocabulary and embeddings +uv pip install "groundworkers[tui,duckdb]" ``` Other extras include `embedding-faiss` for FAISS sidecar acceleration and `all_source` for XLSX, PDF, and DOCX source-planning inputs. See [Installation](usage/installation.md) for the available extras. @@ -57,18 +60,27 @@ Run **Test connections** in Database or **Verify all** in Overview. A working CD See [Configuration](usage/configuration.md) for the shared configuration layout and named references. +For prepared database files, follow the [file configuration requirements](usage/configuration.md#file-backed-databases) +and use the supplied vocabulary and model metadata when configuring them. + ## 4. Prepare graph and search support -Open **Graph → Prepare graph** when readiness reports missing relationship, full-text, or functional indexes. Review the proposed commands before starting. **Performance** shows index readiness and offers further index maintenance. +For PostgreSQL, open **Graph → Prepare graph** when readiness reports missing relationship, full-text, or functional indexes. Review the proposed commands before starting. **Performance** shows index readiness and offers further index maintenance. Preparation runs as a background maintenance task; follow it in **Runs**. The database account used for preparation needs permission to create the required indexes and supporting objects. Normal service queries remain read-only. You can use basic vocabulary operations before preparing every index. Prepare the search and graph support needed by the tools you intend to use, then rerun the checks. +For DuckDB, **Graph → Prepare graph** can load relationship classification. Stop services and close other readers using the CDM file before starting it. PostgreSQL full-text, GIN/trigram indexes and CLUSTER maintenance do not apply to DuckDB and are disabled in this wizard. + ## 5. Add embeddings (optional) Embedding search needs a named embedding model, a named vector store, and vectors for the concepts you want to search. The query model must be compatible with the stored vectors. +For DuckDB CDM and embedding connections, enter absolute file paths in `database_name`; relative paths and `~` aliases are rejected. For a prebuilt DuckDBVSS store, configure its separate file and matching model, then check registration and coverage. To prepare a new DuckDBVSS store, select a separate file in an existing directory and follow the steps below. Stop services and close other readers using the embedding file before initialization. Population also opens the CDM file, so close its readers too. Prepare [offline dependencies](usage/installation.md#prepare-an-offline-deployment) before disconnecting from the network. + +For stores you are preparing through the console: + 1. **Configure the store.** Use Database to select or create the vector-store configuration. 2. **Configure the model.** In Embeddings, choose **Configure model**. Supply the provider details and select a model already registered in the store or a new model from the provider. 3. **Initialize and check.** Use **Initialize embedding store** if needed, then **Check model** to check the store registration and test whether the provider can encode text with that model. @@ -143,7 +155,13 @@ Change the authoritative configuration source, then reopen the console to verify ## Maintenance runs -Graph preparation and embedding population run in separate background processes. You can close and reopen the console while they continue. In **Runs**, follow progress, inspect a redacted log tail, cancel active work, retry a supported failed step, or export commands. Postflight results appear for tasks that define those checks. +Serving and readiness checks are read-only. The setup console can explicitly initialize embedding storage, populate vectors and classify relationships. For DuckDB, stop services and close other readers of the file being written before starting preparation. A conflicting connection causes the operation to fail; close it and retry. Restart the service after preparation completes. + +For bulk CDM loading or index rebuilds, use the omop-alchemy and omop-emb CLIs. Graph preparation uses the classification packaged with omop-graph unless you choose a custom CSV directory in the wizard. + +Close writers cleanly before sharing or reopening the files. Several Groundworkers reader processes can share the same prepared files. Distribute files without outstanding WAL, and record the vocabulary release, package/DuckDB versions, model identity, dimensions and prefixes with them. + +When you start graph preparation or embedding population from the console, it runs in a separate background process. You can close and reopen the console while it continues. In **Runs**, follow progress, inspect a redacted log tail, cancel active work, retry a supported failed step, or export commands. Postflight results appear for tasks that define those checks. Run history is saved on disk. Groundworkers uses `GROUNDWORKERS_STATE_HOME`, then `XDG_STATE_HOME`, as the state base directory and appends `groundworkers`. If neither is set, it uses `~/.local/state/groundworkers`. diff --git a/docs/tools/mapping.md b/docs/tools/mapping.md index d02642b..ab6142a 100644 --- a/docs/tools/mapping.md +++ b/docs/tools/mapping.md @@ -267,5 +267,5 @@ stored ancestor evidence and are reported separately. `ancestor_max_depth` independently sets stored separation (0..20, default 5). `ancestor_scope` reports the full count and whether the display was truncated. Ancestor bounds now reject invalid values rather than silently clamping them. -For the full contract and migration architecture see -[the MCP ancestry design](../development/ancestry-mcp-design.md). +For the runtime contract and bounded relationship-neighborhood behavior see +[stored ancestry and shared targets](../adapters/ancestry.md). diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index fe7b28e..15d5f71 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -53,6 +53,32 @@ Shared resources have their own entries so several packages can use the same con Groundworkers references these entries **by name** and resolves them through `oa-configurator`. Its core runtime reads `[tools.groundworkers]` and the shared entries it names. Maintenance commands and installed plugins use their own package sections, as described below. +## Databases and vector storage + +The CDM holds concepts, vocabulary metadata, ancestry and relationships. A vector +store holds registered embedding models and concept vectors. Choose their +connections separately; each has its own database reference. + +| Storage | Connection `dialect` | Vector-store `backend_type` on that connection | +|---|---|---| +| PostgreSQL | `postgresql+psycopg` | `pgvector` | +| SQLite | `sqlite` | `sqlitevec` | +| DuckDB | `duckdb` | `duckdbvss` | + +For server connections, supply host, port and credentials. File connections use +`database_name` for the file path and need no host or credentials. Select the +required [installation extras](installation.md#optional-extras) for your storage. + +### File-backed databases + +For a DuckDB CDM, the primary and vocabulary roles must use the same existing file. If you enable embeddings, select a separate DuckDBVSS file. Serving and readiness checks open these files read-only. Missing CDM or embedding files stop service startup without creating a database. The setup console can initialize a new embedding file and populate it through explicit preparation actions. + +DuckDB `database_name` values must be absolute file paths. Relative paths and `~` aliases are rejected, so launching from a different directory cannot select a different database. The setup console accepts the CDM as an existing file and offers separate embedding storage; add that connection before selecting it. A new embedding file must have an existing parent directory. Leave schemas blank for files built in their default schema. + +Copy the [DuckDB configuration example](https://github.com/AustralianCancerDataNetwork/groundworkers/blob/main/config/groundworkers.duckdb.example.toml) and replace the paths and model metadata with those supplied with your files. It defines both connections, a CDM database, a generic embedding database, a `duckdbvss` store, and the `[tools.groundworkers]` references that select them. Read-only access is enforced by the runtime; no connection `read_only` TOML field is needed. + +See [database preparation](../from-scratch.md#4-prepare-graph-and-search-support), [maintenance](../from-scratch.md#maintenance-runs) and [offline prerequisites](installation.md#prepare-an-offline-deployment) before starting a service against prepared files. + ## Typical TOML shape This example includes a CDM connection, PostgreSQL embeddings, and an Ollama chat model. Replace the connection details and choose models available from your provider. The model names and grounding threshold are examples, not required defaults. @@ -177,6 +203,8 @@ vocab_schema = "public" Vocabulary reads use `vocab_connection` and `vocab_schema`. If you omit `vocab_connection`, they use the main CDM connection. Different schemas on the same connection need only separate `cdm_schema` and `vocab_schema` values. +DuckDB deployments keep both roles in the same file as described under [prepared database files](#file-backed-databases). + ## Groundworkers settings ### `app_name` @@ -318,6 +346,11 @@ Configuration determines which tools are registered. Registration does not estab Concept lookup, lexical search and grounding, graph and mapping tools, deterministic source planning, knowledge packs, and system tools are available. Some search and graph operations need prepared indexes or supporting objects. No separate `[tools.omop_graph]` section is needed by the core runtime. +PostgreSQL full-text search requires its prepared search objects. It is unavailable +on DuckDB; status reports that limitation while vocabulary lookup, ancestry and +relationship operations remain usable. For normalized search on DuckDB, supply a +domain, vocabulary or parent scope. Full unindexed scans require explicit opt-in. + ### With `vector_store_name` alone Stored-vector operations remain available without a live embedding model. For example, `embedding_neighbours` can find neighbours of a concept already in the store. Supply `model_name` when needed to select the stored model. @@ -328,8 +361,14 @@ Live text encoding, text-based embedding search, and the graph's embedding groun Both references enable live embedding search and embedding-backed grounding and mapping. The provider must be reachable and compatible vectors must already exist in the store. With a model reference alone, embedding store operations remain unavailable. +The query encoder must match the stored model identity, dimensions and query-prefix +convention. Copy that metadata from the prepared store's documentation when +configuring an existing asset. + Groundworkers opens the store read-only for queries, status, and coverage checks. Store initialization and population are explicit write operations. Searches never populate missing vectors. See [Initial local setup](../from-scratch.md#5-add-embeddings-optional) for the preparation steps. +For DuckDB, stop services and close other readers before initialization or population. Population opens both the CDM and embedding files. These explicit actions open a writer; they do not change the read-only serving policy. See [maintenance](../from-scratch.md#maintenance-runs). + The setup wizard uses the `groundworkers` schema for PostgreSQL vector storage. The model registry and configuration provenance use their packages' reserved schemas. SQLite uses its database file without a schema. Each database supports one store per model: reuse an existing registration or choose a different database for another store of that model. A `LEGACY_REGISTRY` error means the store layout is incompatible. Groundworkers does not migrate legacy stores automatically; inspect the diagnostic and use the migration guidance for your installed storage package. Avoid treating a registry error as an empty store. diff --git a/docs/usage/installation.md b/docs/usage/installation.md index b084f29..236785a 100644 --- a/docs/usage/installation.md +++ b/docs/usage/installation.md @@ -18,6 +18,7 @@ Use extras to match the capabilities you plan to run: |---|---| | `embedding-pgvector` | pgvector embedding backend support | | `embedding-faiss` | FAISS sidecar acceleration for embedding search | +| `duckdb` | Read-only DuckDB CDM/graph and DuckDBVSS embedding support | | `xlsx` | XLSX source-planning input support | | `pdf` | PDF source-planning input support | | `docx` | DOCX source-planning input support | @@ -29,8 +30,30 @@ Examples: ```bash pip install "groundworkers[tui,embedding-pgvector]" pip install "groundworkers[all_source]" +pip install "groundworkers[tui,duckdb]" ``` +Choose your CDM connection and vector storage in +[Configuration](configuration.md#databases-and-vector-storage). + +## Prepare an offline deployment + +Install package extras and database extensions while the deployment environment +has network access. Stored-vector operations can run without a live encoder; +text-based embedding search needs access to the configured model provider. + +For DuckDBVSS, install VSS using the same Python environment and operating-system +user that will run Groundworkers: + +```bash +python -c "import duckdb; c = duckdb.connect(); c.install_extension('vss'); c.close()" +``` + +VSS extensions are specific to the DuckDB version and platform. Use the version +recorded with your prepared files. The embedding reader loads VSS when opening +the store and may attempt a network installation if the extension is missing. +Budget RAM for both the vectors and HNSW index; filtered searches may use exact scans. + ## Shared stack prerequisite `groundworkers` runs against the shared OMOP stack config managed by `oa-configurator`. In a typical setup you configure: diff --git a/mkdocs.yaml b/mkdocs.yaml index f505516..2bbe917 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -70,6 +70,7 @@ nav: - Testing: usage/testing.md - CDM adapter: adapters/cdm.md - OMOP graph adapter: adapters/omop_graph.md + - Ancestry and relationship queries: adapters/ancestry.md - OMOP embedding adapter: adapters/omop_emb.md - API Reference: - Tools: reference/ref_tools.md diff --git a/pyproject.toml b/pyproject.toml index 4ca8a12..52a263b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,10 +14,10 @@ dependencies = [ "psycopg[binary]>=3.1,<4", "python-dotenv>=1,<2", "psutil>=5.9,<8", - "oa-configurator>=2.0.0,<3", - "omop-alchemy>=2.0.0,<3", - "omop-graph>=3.0.0,<4", - "omop-emb>=3.0.0,<4", + "oa-configurator>=2.1.0,<3", + "omop-alchemy>=3.0.0,<4", + "omop-graph>=4.0.0,<5", + "omop-emb>=3.1.0,<4", "omop-llm>=1.0.2,<2", "omop-semantics>=0.6,<1", "uvicorn[standard]>=0.29,<1.0", @@ -48,12 +48,17 @@ all_source = [ "python-docx>=1.0", ] embedding-pgvector = [ - "omop-emb[pgvector]>=3.0.0,<4", + "omop-emb[pgvector]>=3.1.0,<4", ] embedding-faiss = [ - "omop-emb[faiss-cpu]>=3.0.0,<4", + "omop-emb[faiss-cpu]>=3.1.0,<4", +] +duckdb = [ + "omop-graph[duckdb]>=4.0.0,<5", + "omop-emb[duckdbvss]>=3.1.0,<4", ] dev = [ + "groundworkers[duckdb]", "httpx>=0.27,<1", "ipython>=8.0", "tornado>=6.5.9", diff --git a/src/groundworkers/adapters/omop_graph.py b/src/groundworkers/adapters/omop_graph.py index 6c93679..07b8faa 100644 --- a/src/groundworkers/adapters/omop_graph.py +++ b/src/groundworkers/adapters/omop_graph.py @@ -8,6 +8,7 @@ from omop_alchemy.cdm.model.vocabulary import ( Concept, Concept_Class, + Concept_Relationship, Domain, Vocabulary, ) @@ -74,12 +75,12 @@ def __init__( embedding_metric: str = "cosine", faiss_cache_dir: str | None = None, ) -> None: - self._engine_compatibility_mode = isinstance(engine, Engine) and cdm_session_factory is None + self._engine_compatibility_mode = ( + isinstance(engine, Engine) and cdm_session_factory is None + ) if isinstance(engine, Engine): self.engine: Engine | None = engine - self._cdm_session_factory = cdm_session_factory or sessionmaker( - bind=engine - ) + self._cdm_session_factory = cdm_session_factory or sessionmaker(bind=engine) else: self.engine = None self._cdm_session_factory = cdm_session_factory or engine @@ -154,7 +155,9 @@ def get_concept(self, concept_id: int) -> dict[str, Any] | None: raise self._wrap_graph_error(exc, default_code="QUERY_ERROR") return self._serialise_concept_view(concept_view) - def get_concept_by_code(self, vocabulary_id: str, code: str) -> list[dict[str, Any]]: + def get_concept_by_code( + self, vocabulary_id: str, code: str + ) -> list[dict[str, Any]]: try: concept_id = self._get_kg().concept_id_by_code(vocabulary_id, code) concept_view = self._get_kg().concept_view(concept_id) @@ -265,6 +268,33 @@ def children(self, concept_id: int) -> tuple[int, ...]: # Edge primitive # ------------------------------------------------------------------ + def relationship_edges( + self, concept_id: int, relationship_ids: list[str], limit: int + ) -> list[dict[str, Any]]: + """Bounded active outbound rows; upstream target is omop-graph query API.""" + statement = ( + select( + Concept_Relationship.concept_id_2, Concept_Relationship.relationship_id + ) + .where( + Concept_Relationship.concept_id_1 == concept_id, + Concept_Relationship.relationship_id.in_(relationship_ids), + func.coalesce(func.trim(Concept_Relationship.invalid_reason), "") == "", + ) + .order_by( + Concept_Relationship.concept_id_2, Concept_Relationship.relationship_id + ) + .limit(limit) + ) + try: + with self._cdm_session_factory() as session: + return [ + {"target_concept_id": cid, "relationship_id": rid, "valid": True} + for cid, rid in session.execute(statement) + ] + except Exception as exc: + raise self._wrap_graph_error(exc, default_code="QUERY_ERROR") from exc + def edges( self, concept_id: int, @@ -584,10 +614,9 @@ def get_vocabulary_catalogue(self) -> dict[str, Any]: .group_by(Domain.domain_id, Domain.domain_name) .order_by(Domain.domain_id) ) - class_stmt = ( - select(Concept_Class.concept_class_id, Concept_Class.concept_class_name) - .order_by(Concept_Class.concept_class_id) - ) + class_stmt = select( + Concept_Class.concept_class_id, Concept_Class.concept_class_name + ).order_by(Concept_Class.concept_class_id) kg = self._get_kg() try: with kg.session_factory() as session: @@ -599,7 +628,11 @@ def get_vocabulary_catalogue(self) -> dict[str, Any]: return { "vocabularies": [ - {"vocabulary_id": r[0], "vocabulary_name": r[1], "concept_count": int(r[2])} + { + "vocabulary_id": r[0], + "vocabulary_name": r[1], + "concept_count": int(r[2]), + } for r in vocab_rows ], "domains": [ @@ -687,13 +720,13 @@ def _get_kg(self) -> KnowledgeGraph: # Broad except: lexical fallback is intentional. emb_config = None self._embedding_configured = False - self._embedding_configuration_error = self._embedding_failure_detail(exc) + self._embedding_configuration_error = self._embedding_failure_detail( + exc + ) logger.warning("%s", self._embedding_configuration_error) try: - self._kg = KnowledgeGraph( - self._cdm_session_factory, emb_config=emb_config - ) + self._kg = KnowledgeGraph(self._cdm_session_factory, emb_config=emb_config) except Exception as exc: if emb_config is None: self._embedding_configured = False @@ -707,7 +740,9 @@ def _get_kg(self) -> KnowledgeGraph: try: self._kg = KnowledgeGraph(self._cdm_session_factory, emb_config=None) except Exception as lexical_exc: - raise self._wrap_graph_error(lexical_exc, default_code="BACKEND_UNAVAIL") + raise self._wrap_graph_error( + lexical_exc, default_code="BACKEND_UNAVAIL" + ) return self._kg @staticmethod @@ -745,9 +780,13 @@ def _serialise_ground_hit(self, result: Any) -> dict[str, Any]: "matched_label": getattr(result, "matched_concept_label", None), "total_score": round(float(result.total_score), 4), "relevance": round(float(getattr(result, "relevance", 0.0)), 4), - "parsimony_penalty": round(float(getattr(result, "parsimony_penalty", 0.0)), 4), + "parsimony_penalty": round( + float(getattr(result, "parsimony_penalty", 0.0)), 4 + ), "broadness_bonus": round(float(getattr(result, "broadness_bonus", 0.0)), 4), - "embedding_score": round(float(emb_score), 4) if emb_score is not None else None, + "embedding_score": round(float(emb_score), 4) + if emb_score is not None + else None, "separation": int(getattr(result, "separation", 0)), "standardized_from": standardized_from, } @@ -772,7 +811,10 @@ def _label_match_kind_name(cls, match_kind: object) -> str: def _is_not_found(exc: Exception) -> bool: if isinstance(exc, NoResultFound): return True - return any(cls.__name__ in {"NotFoundError", "ConceptNotFoundError"} for cls in type(exc).__mro__) + return any( + cls.__name__ in {"NotFoundError", "ConceptNotFoundError"} + for cls in type(exc).__mro__ + ) @staticmethod def _wrap_graph_error(exc: Exception, *, default_code: str) -> GroundworkersError: diff --git a/src/groundworkers/app.py b/src/groundworkers/app.py index f4e26a3..dae2463 100644 --- a/src/groundworkers/app.py +++ b/src/groundworkers/app.py @@ -2,6 +2,7 @@ import logging from collections.abc import Callable +from contextlib import ExitStack from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any @@ -83,7 +84,10 @@ class GroundworkersApp: plugin_issues: dict[str, str] = field(default_factory=dict) -def build_adapters(config: AppConfig) -> Adapters: +def build_adapters( + config: AppConfig, *, resources: ExitStack | None = None +) -> Adapters: + """Build shared adapters; a scoped owner can release their lazy store reader.""" adapters = Adapters() adapters.cdm = CDMAdapter( config.cdm_engine, @@ -103,6 +107,8 @@ def get_embedding_backend() -> EmbeddingStoreReader: from omop_emb.backends import open_vector_store_reader shared_backend = open_vector_store_reader(resolved_store) + if resources is not None: + resources.callback(shared_backend.close) return shared_backend shared_model_backend: ModelBackend | None = None @@ -333,27 +339,30 @@ def verify_plugin_readiness( configured=False, summary="This plugin does not expose readiness verification.", ) - adapters = build_adapters(config) - services = build_services(config, adapters) - context = _build_plugin_context(config, adapters, services) - state, issue = _build_plugin(config, context, plugin) - if state is None: - return PluginReadinessResult( - state=PluginReadinessState.UNCONFIGURED, - configured=False, - summary=( - "Plugin configuration or prerequisites are unavailable. " - f"Host detail: {issue or 'unknown issue'}." - ), - ) - try: - return plugin.verify_readiness(state) - except Exception: - logger.exception("Groundworkers plugin %s readiness check failed.", plugin.name) - return PluginReadinessResult( - state=PluginReadinessState.ERROR, - summary="Plugin verification failed unexpectedly; review the host logs.", - ) + with ExitStack() as resources: + adapters = build_adapters(config, resources=resources) + services = build_services(config, adapters) + context = _build_plugin_context(config, adapters, services) + state, issue = _build_plugin(config, context, plugin) + if state is None: + return PluginReadinessResult( + state=PluginReadinessState.UNCONFIGURED, + configured=False, + summary=( + "Plugin configuration or prerequisites are unavailable. " + f"Host detail: {issue or 'unknown issue'}." + ), + ) + try: + return plugin.verify_readiness(state) + except Exception: + logger.exception( + "Groundworkers plugin %s readiness check failed.", plugin.name + ) + return PluginReadinessResult( + state=PluginReadinessState.ERROR, + summary="Plugin verification failed unexpectedly; review the host logs.", + ) def load_plugin_readiness( @@ -380,7 +389,12 @@ def load_plugin_readiness( "configure this plugin." ), ) - return verify_plugin_readiness(config, plugin) + try: + return verify_plugin_readiness(config, plugin) + finally: + config.cdm_engine.dispose() + if config.cdm_vocab_engine is not config.cdm_engine: + config.cdm_vocab_engine.dispose() def build_application(config: AppConfig) -> GroundworkersApp: diff --git a/src/groundworkers/application/setup/configuration_provider.py b/src/groundworkers/application/setup/configuration_provider.py index 57ff84f..efe0fd0 100644 --- a/src/groundworkers/application/setup/configuration_provider.py +++ b/src/groundworkers/application/setup/configuration_provider.py @@ -68,6 +68,7 @@ EmbeddingStoreSnapshot, EmbeddingStoreState, ) +from groundworkers.base.database import duckdb_file_path, same_database_file from groundworkers.base.errors import scrub_error_message from groundworkers.config import GroundworkersConfig @@ -100,8 +101,20 @@ # Targets whose first step discovers live models from a provider endpoint. _PROVIDER_DISCOVERY_STEPS: Final = { - MODEL_SETUP_TARGET: ("provider", "provider_kind", "base_url", "api_key", "model_choice"), - LLM_SETUP_TARGET: ("provider", "llm_provider_kind", "llm_base_url", "llm_api_key", "llm_model_choice"), + MODEL_SETUP_TARGET: ( + "provider", + "provider_kind", + "base_url", + "api_key", + "model_choice", + ), + LLM_SETUP_TARGET: ( + "provider", + "llm_provider_kind", + "llm_base_url", + "llm_api_key", + "llm_model_choice", + ), } ModelDiscoverer = Callable[[str, str | None, str | None], Sequence[str]] @@ -127,10 +140,12 @@ _BACKEND_FOR_DIALECT: Final = { "sqlite": "sqlitevec", "postgresql": "pgvector", + "duckdb": "duckdbvss", } _BACKEND_CHOICES: Final = ( ChoiceOption("sqlitevec", "sqlite-vec (single file, no server)"), ChoiceOption("pgvector", "pgvector (PostgreSQL extension)"), + ChoiceOption("duckdbvss", "DuckDB VSS (existing file)"), ) ApplyCallback = Callable[[], None] @@ -274,6 +289,30 @@ def submit( ) future_fields: tuple[FieldSpec, ...] = () + previous_dialect = session.answers.get( + "dialect", + next( + (field.default for field in session.fields if field.key == "dialect"), + None, + ), + ) + if ( + session.target == CDM_SETUP_TARGET + and step_key == "connection" + and proposed.get("dialect") == "duckdb" + and previous_dialect != "duckdb" + ): + # The wizard starts with PostgreSQL defaults. Refresh the schema + # field before its later form can reapply "public" to a file CDM. + future_fields = ( + FieldSpec("cdm_schema", "CDM schema", required=False, default="main"), + FieldSpec( + "vocab_schema", "Vocabulary schema", required=False, default=None + ), + FieldSpec( + "results_schema", "Results schema", required=False, default=None + ), + ) discovery = _PROVIDER_DISCOVERY_STEPS.get(session.target) if discovery is not None and step_key == discovery[0]: _, kind_key, base_url_key, api_key_key, choice_key = discovery @@ -313,7 +352,10 @@ def submit( changed_fields=session.changed_fields, ) session.provider_models = models - if session.target is MODEL_SETUP_TARGET or session.target == MODEL_SETUP_TARGET: + if ( + session.target is MODEL_SETUP_TARGET + or session.target == MODEL_SETUP_TARGET + ): # The registry step comes next for embeddings, so refresh its # choice with what the store actually holds. future_fields = (self._model_source_field(session),) @@ -358,9 +400,7 @@ def submit( def _model_source_field(self, session: _MutationSession) -> FieldSpec: """Offer the registry only when it actually holds something.""" store = self._registry_lister(self._load_snapshot()) - session.registered_models = tuple( - model.model_name for model in store.models - ) + session.registered_models = tuple(model.model_name for model in store.models) registered = session.registered_models if not registered: return FieldSpec( @@ -400,9 +440,7 @@ def _model_choice_field( _optional_text(proposed.get("model_source")) == MODEL_SOURCE_REGISTERED and session.registered_models ) - names = ( - session.registered_models if use_registered else session.provider_models - ) + names = session.registered_models if use_registered else session.provider_models choices, default, choice_help = _model_choice_options( session.fields, "model_choice", names ) @@ -614,7 +652,7 @@ def _submission_issues( if key in submitted and not _optional_text(submitted.get(key)): issues.append(ValidationIssue("A name is required.", field_key=key)) dialect = _optional_text(proposed.get("dialect")) - if dialect not in {"sqlite", "postgresql+psycopg"}: + if dialect not in {"sqlite", "postgresql+psycopg", "duckdb"}: issues.append( ValidationIssue( "Choose a supported database type.", field_key="dialect" @@ -622,18 +660,25 @@ def _submission_issues( ) if ( step_key == "server" - and dialect != "sqlite" + and dialect not in {"sqlite", "duckdb"} and not _optional_text(proposed.get("host")) ): issues.append( ValidationIssue("A database host is required.", field_key="host") ) - if step_key == "database" and dialect == "sqlite": + if step_key == "database" and dialect in {"sqlite", "duckdb"}: sqlite_path = _optional_text(proposed.get("database_name")) if sqlite_path == ":memory:": issues.append( ValidationIssue( - "An in-memory SQLite database cannot be used as an OMOP CDM.", + "An in-memory database cannot be used as an OMOP CDM.", + field_key="database_name", + ) + ) + elif sqlite_path and dialect == "duckdb" and not Path(sqlite_path).is_absolute(): + issues.append( + ValidationIssue( + "Use an absolute CDM file path without ~.", field_key="database_name", ) ) @@ -643,7 +688,7 @@ def _submission_issues( ): issues.append( ValidationIssue( - "Choose an existing SQLite CDM file.", + "Choose an existing CDM file.", field_key="database_name", ) ) @@ -714,18 +759,24 @@ def _submission_issues( issues.append( ValidationIssue("A value is required.", field_key=key) ) + issues.extend(_duckdb_store_issues(session.base, step_key, proposed)) elif target == LLM_SETUP_TARGET: if step_key == "provider": if not _optional_text(proposed.get("llm_provider_name")): issues.append( ValidationIssue( - "A provider name is required.", field_key="llm_provider_name" + "A provider name is required.", + field_key="llm_provider_name", ) ) - if _optional_text(proposed.get("llm_provider_kind")) not in supported_provider_keys(): + if ( + _optional_text(proposed.get("llm_provider_kind")) + not in supported_provider_keys() + ): issues.append( ValidationIssue( - "Choose a supported provider.", field_key="llm_provider_kind" + "Choose a supported provider.", + field_key="llm_provider_kind", ) ) if not _optional_text(proposed.get("llm_base_url")): @@ -803,7 +854,7 @@ def cdm_setup_workflow( when=( ConfigBranchCondition( "dialect", - frozenset({"sqlite"}), + frozenset({"sqlite", "duckdb"}), negated=True, ), ), @@ -928,7 +979,12 @@ def llm_setup_workflow( ConfigWorkflowStep( "provider", "Connect to the provider", - ("llm_provider_name", "llm_provider_kind", "llm_base_url", "llm_api_key"), + ( + "llm_provider_name", + "llm_provider_kind", + "llm_base_url", + "llm_api_key", + ), ), ConfigWorkflowStep( "model", @@ -961,6 +1017,7 @@ def _cdm_fields(stack: StackConfig) -> tuple[FieldSpec, ...]: choices=( ChoiceOption("postgresql+psycopg", "PostgreSQL (recommended)"), ChoiceOption("sqlite", "SQLite (Advanced: existing file)"), + ChoiceOption("duckdb", "DuckDB (read-only existing file)"), ), ), FieldSpec( @@ -991,11 +1048,11 @@ def _cdm_fields(stack: StackConfig) -> tuple[FieldSpec, ...]: ), FieldSpec( "database_name", - "Database name or SQLite path", + "Database name or file path", default=getattr(connection, "database_name", None), help=( "Enter the PostgreSQL database containing populated OMOP vocabulary tables. " - "For SQLite, select an existing CDM file." + "For SQLite or DuckDB, select an existing CDM file. DuckDB requires an absolute path without ~." ), ), FieldSpec( @@ -1017,7 +1074,7 @@ def _cdm_fields(stack: StackConfig) -> tuple[FieldSpec, ...]: default=( getattr(database, "cdm_schema", None) if database is not None - else ("public" if _supports_schema_dialect(dialect) else None) + else ("public" if dialect.startswith("postgresql") else None) ), ), FieldSpec( @@ -1085,7 +1142,9 @@ def _model_fields(stack: StackConfig) -> tuple[FieldSpec, ...]: "base_url", "Provider endpoint", required=False, - default=_default_endpoint(provider_kind, getattr(provider, "base_url", None)), + default=_default_endpoint( + provider_kind, getattr(provider, "base_url", None) + ), help=_endpoint_help(provider_kind), ), FieldSpec( @@ -1160,8 +1219,10 @@ def _vector_store_fields(stack: StackConfig) -> tuple[FieldSpec, ...]: ) store_connection = stack.connections.get(store_connection_name) store_schema = getattr(database, "schema_name", None) - if store_schema is None and _supports_schema_dialect( - getattr(store_connection, "dialect", None) + if ( + store_schema is None + and _supports_schema_dialect(getattr(store_connection, "dialect", None)) + and getattr(store_connection, "dialect", "").startswith("postgresql") ): store_schema = GROUNDWORKERS_SCHEMA return ( @@ -1221,6 +1282,64 @@ def _vector_store_fields(stack: StackConfig) -> tuple[FieldSpec, ...]: ) +def _duckdb_store_issues( + stack: StackConfig, step_key: str, proposed: Mapping[str, object] +) -> list[ValidationIssue]: + """Validate DuckDB's separate embedding-file topology for setup.""" + if ( + step_key == "location" + and proposed.get("store_location") == STORE_LOCATION_CDM + and _backend_for_stack(stack) == "duckdbvss" + ): + return [ + ValidationIssue( + "Choose a separate embedding file; vectors cannot share the DuckDB CDM file.", + field_key="store_location", + ) + ] + if step_key != "database" or proposed.get("backend_type") != "duckdbvss": + return [] + connection_name = str(proposed.get("store_connection_name", "")) + connection = stack.connections.get(connection_name) + if connection is None or connection.dialect != "duckdb": + return [ + ValidationIssue( + "Choose a DuckDB embedding-file connection.", + field_key="store_connection_name", + ) + ] + _, cdm = _cdm_entry(stack) + try: + path = duckdb_file_path(connection.database_name) + cdm_paths = ( + [ + duckdb_file_path(other.database_name) + for name in (cdm.connection, cdm.vocab_connection) + for other in (stack.connections.get(name),) + if other is not None and other.dialect == "duckdb" + ] + if cdm is not None + else [] + ) + except ConfigurationError as exc: + return [ValidationIssue(str(exc), field_key="store_connection_name")] + if any(same_database_file(path, other) for other in cdm_paths): + return [ + ValidationIssue( + "The embedding file must be separate from the CDM file.", + field_key="store_connection_name", + ) + ] + if (path.exists() and not path.is_file()) or not path.parent.is_dir(): + return [ + ValidationIssue( + "Choose a regular DuckDB embedding file or a new file in an existing directory; use an absolute path without ~.", + field_key="store_connection_name", + ) + ] + return [] + + def _cdm_entry(stack: StackConfig) -> tuple[str, CDMDatabaseConfig | None]: """The CDM database entry Groundworkers is pointed at, and its name.""" @@ -1244,6 +1363,15 @@ def _store_location_field( separate = ChoiceOption( STORE_LOCATION_SEPARATE, "Somewhere else (choose a connection and schema)" ) + if cdm is not None and _backend_for_stack(stack) == "duckdbvss": + return FieldSpec( + "store_location", + "Vector storage", + kind=FieldKind.CHOICE, + default=STORE_LOCATION_SEPARATE, + choices=(separate,), + help="Select the separate prebuilt DuckDB embedding file; the CDM is served read-only.", + ) if cdm is None: return FieldSpec( "store_location", @@ -1282,9 +1410,7 @@ def _store_location_field( ) -def _matches_cdm( - database: object, cdm: CDMDatabaseConfig, stack: StackConfig -) -> bool: +def _matches_cdm(database: object, cdm: CDMDatabaseConfig, stack: StackConfig) -> bool: if getattr(database, "connection", None) != cdm.connection: return False connection = stack.connections.get(cdm.connection) @@ -1397,7 +1523,9 @@ def _llm_fields(stack: StackConfig) -> tuple[FieldSpec, ...]: "llm_base_url", "Provider endpoint", required=False, - default=_default_endpoint(provider_kind, getattr(provider, "base_url", None)), + default=_default_endpoint( + provider_kind, getattr(provider, "base_url", None) + ), help=_endpoint_help(provider_kind), ), FieldSpec( @@ -1434,7 +1562,7 @@ def _cdm_set_dict(answers: Mapping[str, object]) -> dict[str, object]: } for key in ("host", "port", "user", "password"): value = answers.get(key) - if value not in (None, ""): + if value not in (None, "") and answers["dialect"] not in {"sqlite", "duckdb"}: connection[key] = value database: dict[str, object] = { "name": str(answers["cdm_db_name"]), @@ -1442,6 +1570,8 @@ def _cdm_set_dict(answers: Mapping[str, object]) -> dict[str, object]: } if _supports_schema_dialect(str(answers["dialect"])): schema = answers.get("cdm_schema") + if answers["dialect"] == "duckdb" and schema in (None, ""): + schema = "main" if schema not in (None, ""): database["cdm_schema"] = schema for key in ("vocab_schema", "results_schema"): @@ -1560,7 +1690,10 @@ def _llm_set_dict(answers: Mapping[str, object]) -> dict[str, object]: "name": str(answers["llm_provider_name"]), "provider": str(answers["llm_provider_kind"]), } - for answer_key, provider_key in (("llm_base_url", "base_url"), ("llm_api_key", "api_key")): + for answer_key, provider_key in ( + ("llm_base_url", "base_url"), + ("llm_api_key", "api_key"), + ): value = answers.get(answer_key) if value not in (None, ""): provider[provider_key] = value @@ -1613,10 +1746,7 @@ def _align_omop_emb_config( """ resolved = groundworkers or GroundworkersConfig.validate_candidate(stack) - if ( - resolved.embedding_model_name is None - or resolved.vector_store_name is None - ): + if resolved.embedding_model_name is None or resolved.vector_store_name is None: return stack return plan_configure( _registered_package_config(_OMOP_EMB_TOOL_NAME), @@ -1789,9 +1919,7 @@ def _effects_for(session: _MutationSession) -> tuple[EffectRef, ...]: # Named the same way `_store_database` names it, so the effect an operator # is shown is the entry the apply actually writes. database_name = str( - _store_database(session.answers, session.base).get( - "name", "embedding_db" - ) + _store_database(session.answers, session.base).get("name", "embedding_db") ) return ( EffectRef( @@ -1805,9 +1933,7 @@ def _effects_for(session: _MutationSession) -> tuple[EffectRef, ...]: session.operation.value, session.target, "embedding store", - ConfigTarget( - ConfigTargetKind.VECTOR_STORE, store_name, store_name - ), + ConfigTarget(ConfigTargetKind.VECTOR_STORE, store_name, store_name), "vector_stores", ), ) @@ -1946,7 +2072,9 @@ def _convention_for_stored_prefixes( return PREFIX_CONVENTION_CUSTOM -def _prefix_convention_field(model_name: str | None, *, default: str | None) -> FieldSpec: +def _prefix_convention_field( + model_name: str | None, *, default: str | None +) -> FieldSpec: return FieldSpec( "prefix_convention", "Prefix convention", @@ -2027,11 +2155,19 @@ def _model_choice_options( options = tuple(ChoiceOption(name, name) for name in discovered) if stored and stored not in discovered: options = (ChoiceOption(stored, f"{stored} (stored; not discovered)"), *options) - return options, stored, ( - f"Stored model {stored!r} was not reported by the provider. It remains selected; verify it before changing." + return ( + options, + stored, + ( + f"Stored model {stored!r} was not reported by the provider. It remains selected; verify it before changing." + ), ) - return options, stored or (discovered[0] if discovered else None), ( - "Models reported by the accepted provider endpoint. Populate embeddings after saving to use a newly selected model." + return ( + options, + stored or (discovered[0] if discovered else None), + ( + "Models reported by the accepted provider endpoint. Populate embeddings after saving to use a newly selected model." + ), ) @@ -2051,7 +2187,6 @@ def _no_model_discovery( raise RuntimeError("No model discovery service is configured.") - # Derived from omop-llm rather than hand-listed. A hardcoded list previously # offered "openai-compatible", which any-llm does not recognise, so a chat # provider configured through the console failed at runtime with diff --git a/src/groundworkers/application/setup/databases.py b/src/groundworkers/application/setup/databases.py index 5367ccc..e823be5 100644 --- a/src/groundworkers/application/setup/databases.py +++ b/src/groundworkers/application/setup/databases.py @@ -20,7 +20,6 @@ physical_schema_of, supports_schemas, ) -from omop_alchemy.config import create_cdm_engines from sqlalchemy import ( Column, MetaData, @@ -45,6 +44,11 @@ DiagnosticSeverity, ResourceDiagnostic, ) +from groundworkers.base.database import ( + create_runtime_cdm_engines, + duckdb_file_path, + validate_duckdb_embedding_file, +) from groundworkers.base.errors import embedding_store_error from groundworkers.base.sql import ( normalization_key_expression_sql, @@ -223,7 +227,16 @@ def verify_database_target( engine: Engine | None = None started = clock() try: - engine = engine_factory(target.connection_url) + if engine_factory is create_engine and target.connection_url.startswith("duckdb:"): + if target.resolved_vector_store is not None: + validate_duckdb_embedding_file(target.resolved_vector_store) + else: + from sqlalchemy.engine import make_url + + duckdb_file_path(make_url(target.connection_url).database) + engine = create_engine(target.connection_url, connect_args={"read_only": True}) + else: + engine = engine_factory(target.connection_url) with engine.connect() as connection: connection.execute(select(literal(1))) diagnostics = _diagnostics_for_target(connection, connection, target) @@ -259,9 +272,7 @@ def _verify_cdm_target( resolved_cdm_database = target.resolved_cdm_database if resolved_cdm_database is None: raise ValueError("CDM database configuration is missing") - engines = create_cdm_engines( - resolved_cdm_database, register_claims=False - ) + engines = create_runtime_cdm_engines(resolved_cdm_database) primary_engine, vocab_engine = engines with primary_engine.connect() as primary, vocab_engine.connect() as vocab: try: @@ -568,7 +579,7 @@ def _groundworkers_embedding_model_diagnostics( return ( _warning( "grounding_embedding_model_unchecked", - f"Groundworkers grounding model {expected_model_name!r} could not be checked because no pgvector embedding store is configured.", + f"Groundworkers grounding model {expected_model_name!r} could not be checked because no embedding store is configured.", ), ) @@ -677,6 +688,19 @@ def classify_connection_error(exc: BaseException) -> ClassifiedFailure: if store_failure is not None: return store_failure lowered = " ".join(str(item).lower() for item in chain) + if any( + phrase in lowered + for phrase in ( + "could not set lock on file", + "conflicting lock is held", + "different configuration than existing connections", + ) + ): + return _failure( + ConnectionFailureKind.OTHER, + "The DuckDB file is already open with a conflicting connection.", + "Stop services and close other readers or writers using this file, then retry preparation.", + ) sqlstate = next( ( str(value) diff --git a/src/groundworkers/application/setup/embedding_population.py b/src/groundworkers/application/setup/embedding_population.py index 8ad7889..54038dd 100644 --- a/src/groundworkers/application/setup/embedding_population.py +++ b/src/groundworkers/application/setup/embedding_population.py @@ -9,7 +9,6 @@ from groundskeeping.contracts import Command, CommandPlan, CommandStep from oa_configurator import ResolvedCDMDatabase, Resolver, safe_endpoint -from omop_alchemy.config import create_cdm_engines from omop_alchemy.cross_database import cdm_sessionmaker from omop_llm import canonical_model_name from sqlalchemy.engine import Engine @@ -29,6 +28,11 @@ EmbeddingIndexSnapshot, EmbeddingPopulationRequest, ) +from groundworkers.base.database import ( + create_runtime_cdm_engines, + duckdb_file_path, + validate_duckdb_embedding_file, +) from groundworkers.base.errors import embedding_store_error from groundworkers.base.results import enum_value from groundworkers.config import GroundworkersConfig @@ -63,12 +67,13 @@ def load_embedding_coverage_report( groundworkers.embedding_model_name is None or groundworkers.vector_store_name is None ): - raise ValueError("Embedding model and vector store references are required.") + raise ValueError( + "Embedding model and vector store references are required." + ) resolver = Resolver(snapshot.stack) resolved_model = resolver.resolve_model(groundworkers.embedding_model_name) - vector_store = resolver.resolve_vector_store( - groundworkers.vector_store_name - ) + vector_store = resolver.resolve_vector_store(groundworkers.vector_store_name) + validate_duckdb_embedding_file(vector_store) store = open_vector_store_reader(vector_store) cdm_session_factory, cdm_engines = _cdm_session_factory_and_engines(snapshot) try: @@ -175,6 +180,8 @@ def build_embedding_population_command( ) -> Command: """Build the omop-emb command used to populate missing concept embeddings.""" + if configuration.backend == "duckdbvss": + duckdb_file_path(configuration.database_path) executable = shutil.which("omop-emb") or "omop-emb" argv: list[str] = [ executable, @@ -248,13 +255,15 @@ def _cdm_session_factory_and_engines( database = resolver.resolve_database(groundworkers.cdm_db) if not isinstance(database, ResolvedCDMDatabase): raise ValueError("Groundworkers cdm_db must resolve to a CDM database.") - primary_engine, vocab_engine = create_cdm_engines( - database, register_claims=False - ) + primary_engine, vocab_engine = create_runtime_cdm_engines(database) session_factory = cdm_sessionmaker( database, primary=primary_engine, vocab=vocab_engine ) - engines = (primary_engine,) if vocab_engine is primary_engine else (primary_engine, vocab_engine) + engines = ( + (primary_engine,) + if vocab_engine is primary_engine + else (primary_engine, vocab_engine) + ) return session_factory, engines diff --git a/src/groundworkers/application/setup/embedding_registry.py b/src/groundworkers/application/setup/embedding_registry.py index 0c35779..efd91a8 100644 --- a/src/groundworkers/application/setup/embedding_registry.py +++ b/src/groundworkers/application/setup/embedding_registry.py @@ -19,6 +19,7 @@ EmbeddingStoreState, RegisteredEmbeddingModel, ) +from groundworkers.base.database import validate_duckdb_embedding_file from groundworkers.config import GroundworkersConfig __all__ = ["RegistryLister", "list_registered_models"] @@ -56,6 +57,7 @@ def list_registered_models(snapshot: ConfigurationSnapshot) -> EmbeddingStoreSna try: from omop_emb.backends import open_vector_store_reader + validate_duckdb_embedding_file(resolved) store = open_vector_store_reader(resolved) records = store.get_registered_models() models_list = [] diff --git a/src/groundworkers/application/setup/embedding_setup.py b/src/groundworkers/application/setup/embedding_setup.py index e809104..dc93765 100644 --- a/src/groundworkers/application/setup/embedding_setup.py +++ b/src/groundworkers/application/setup/embedding_setup.py @@ -4,6 +4,7 @@ from pathlib import Path from oa_configurator import ( + ResolvedCDMDatabase, ResolvedModel, ResolvedVectorStore, Resolver, @@ -30,6 +31,10 @@ ProviderSnapshot, RegisteredEmbeddingModel, ) +from groundworkers.base.database import ( + validate_duckdb_cdm_paths, + validate_duckdb_embedding_file, +) from groundworkers.base.results import enum_value, required_enum_value from groundworkers.config import GroundworkersConfig @@ -63,6 +68,12 @@ def initialize_embedding_store( resolved = Resolver(snapshot.stack).resolve_vector_store( groundworkers.vector_store_name ) + if resolved.backend_type == "duckdbvss": + cdm = Resolver(snapshot.stack).resolve_database(groundworkers.cdm_db) + if not isinstance(cdm, ResolvedCDMDatabase): + raise ValueError("Groundworkers requires a CDM database.") + validate_duckdb_cdm_paths(cdm) + validate_duckdb_embedding_file(resolved, cdm=cdm, allow_create=True) if initializer is None: from omop_emb.backends import open_vector_store_writer @@ -123,9 +134,7 @@ def load_embedding_configuration( return None resolver = Resolver(stack) model = resolver.resolve_model(groundworkers.embedding_model_name) - vector_store = resolver.resolve_vector_store( - groundworkers.vector_store_name - ) + vector_store = resolver.resolve_vector_store(groundworkers.vector_store_name) except (KeyError, TypeError, ValueError): return None @@ -136,11 +145,18 @@ def load_embedding_configuration( database_path = ( connection_config.database_name if connection_config.dialect.startswith("sqlite") + or connection_config.dialect == "duckdb" else None ) config_base = snapshot.path.parent if snapshot.path is not None else None database_path_exists = ( - _path_exists(database_path, base=config_base) + bool( + database_path + and Path(database_path).is_absolute() + and Path(database_path).is_file() + ) + if connection_config.dialect == "duckdb" + else _path_exists(database_path, base=config_base) if database_path is not None else None ) @@ -160,7 +176,9 @@ def load_embedding_configuration( model_entry_name=model.name, model_name=model.model, embeddings_supported=model.embeddings, - api_base=(safe_endpoint(model.provider.base_url) if model.provider.base_url else None), + api_base=( + safe_endpoint(model.provider.base_url) if model.provider.base_url else None + ), database_path=database_path, database_path_exists=database_path_exists, faiss_cache_dir=vector_store.faiss_cache_dir, diff --git a/src/groundworkers/application/setup/graph_maintenance.py b/src/groundworkers/application/setup/graph_maintenance.py index d2f44ce..3089d5a 100644 --- a/src/groundworkers/application/setup/graph_maintenance.py +++ b/src/groundworkers/application/setup/graph_maintenance.py @@ -12,10 +12,10 @@ import sys from dataclasses import dataclass from enum import StrEnum -from importlib import resources from pathlib import Path from groundskeeping.contracts import Command, CommandPlan, CommandStep +from oa_configurator import ResolvedCDMDatabase, Resolver, StackConfig from groundworkers.application.setup.maintenance_runs import ( MaintenanceRun, @@ -25,6 +25,8 @@ ConnectionResult, DiagnosticSeverity, ) +from groundworkers.base.database import validate_duckdb_cdm_paths +from groundworkers.config import GroundworkersConfig __all__ = [ "PREDICATE_CSV_NAMES", @@ -32,34 +34,16 @@ "GraphRemediationRequest", "build_fulltext_commands", "build_graph_remediation_commands", + "cdm_is_duckdb", "outstanding_remediations", - "packaged_predicate_csv_dir", "start_graph_remediation_run", + "validate_graph_preparation_paths", ] -# The two files omop-graph's loader reads. Named here because both the packaged -# lookup and the wizard's validation need the same list. +# The two files required when an operator chooses a custom classification directory. PREDICATE_CSV_NAMES = ("predicate_classification.csv", "predicate_mapping.csv") -def packaged_predicate_csv_dir() -> Path | None: - """The predicate CSVs bundled with Groundworkers, if they are present. - - TEMPORARY. These are a verbatim copy of omop-graph's own `config/` files, - which live outside its packaged directory and so never reach its wheel -- - leaving `omop-graph relationship-classification` unrunnable from a normal - install. An upstream issue is open to ship them; delete this function, the - `config/` directory, and the wizard's default once it lands. - - Returns ``None`` rather than a missing path so the caller can fall back to - asking, instead of offering a default that does not exist. - """ - bundled = Path(str(resources.files("groundworkers") / "config")) - if all((bundled / name).is_file() for name in PREDICATE_CSV_NAMES): - return bundled - return None - - class GraphRemediation(StrEnum): """A gap the readiness check can report, and the command that closes it.""" @@ -88,6 +72,7 @@ class GraphRemediationRequest: actions: tuple[GraphRemediation, ...] predicate_csv_dir: Path | None = None cluster: bool = True + duckdb: bool = False def build_fulltext_commands( @@ -113,6 +98,31 @@ def build_fulltext_commands( ) +def cdm_is_duckdb(stack: StackConfig | None) -> bool: + """Detect DuckDB so setup can omit PostgreSQL-specific maintenance.""" + if stack is None: + return False + try: + config = GroundworkersConfig.validate_candidate(stack) + database = Resolver(stack).resolve_database(config.cdm_db) + except (ValueError, TypeError, KeyError): + return False + return isinstance(database, ResolvedCDMDatabase) and any( + connection.dialect_name == "duckdb" + for connection in (database.connection, database.vocab_connection) + ) + + +def validate_graph_preparation_paths(stack: StackConfig | None) -> None: + """Validate CDM filenames before an upstream maintenance CLI can open a writer.""" + if stack is None: + raise ValueError("Configure the CDM database before preparing the graph.") + config = GroundworkersConfig.validate_candidate(stack) + cdm = Resolver(stack).resolve_database(config.cdm_db) + if isinstance(cdm, ResolvedCDMDatabase): + validate_duckdb_cdm_paths(cdm) + + def outstanding_remediations( readiness: ConnectionResult | None, ) -> tuple[GraphRemediation, ...]: @@ -142,12 +152,16 @@ def build_graph_remediation_commands( ) -> tuple[Command, ...]: """Build the CLI commands for the selected remediations, in dependency order. - Raises - ------ - ValueError - If relationship classification is selected without a source directory, - which omop-graph requires and does not ship. + The upstream packaged classification is used unless a directory is supplied. """ + if request.duckdb and any( + action is not GraphRemediation.RELATIONSHIP_CLASSIFICATION + for action in request.actions + ): + raise ValueError( + "Full-text and functional index preparation requires PostgreSQL. " + "DuckDB supports relationship classification only." + ) environment: tuple[tuple[str, str], ...] = () if config_path is not None: environment = (("OA_CONFIG_PATH", str(Path(config_path).expanduser())),) @@ -156,19 +170,12 @@ def build_graph_remediation_commands( selected = set(request.actions) if GraphRemediation.RELATIONSHIP_CLASSIFICATION in selected: - if request.predicate_csv_dir is None: - raise ValueError( - "Relationship classification needs a directory holding " - "predicate_classification.csv and predicate_mapping.csv." - ) + argv = [_executable("omop-graph"), "relationship-classification"] + if request.predicate_csv_dir is not None: + argv.extend(("--pred-class-dir", str(request.predicate_csv_dir))) commands.append( Command( - argv=( - _executable("omop-graph"), - "relationship-classification", - "--pred-class-dir", - str(request.predicate_csv_dir), - ), + argv=tuple(argv), environment=environment, ) ) @@ -182,9 +189,7 @@ def build_graph_remediation_commands( # CLUSTER rewrites the whole heap; on large vocabulary tables that # needs disk headroom the operator may not have. argv.append("--no-cluster") - commands.append( - Command(argv=tuple(argv), environment=environment) - ) + commands.append(Command(argv=tuple(argv), environment=environment)) return tuple(commands) @@ -214,4 +219,6 @@ def start_graph_remediation_run( def _executable(name: str) -> str: beside_python = Path(sys.executable).with_name(name) - return str(beside_python) if beside_python.is_file() else (shutil.which(name) or name) + return ( + str(beside_python) if beside_python.is_file() else (shutil.which(name) or name) + ) diff --git a/src/groundworkers/application/setup/performance_maintenance.py b/src/groundworkers/application/setup/performance_maintenance.py index c6b89b3..8455d5b 100644 --- a/src/groundworkers/application/setup/performance_maintenance.py +++ b/src/groundworkers/application/setup/performance_maintenance.py @@ -138,6 +138,8 @@ def _postgres_cdm( database = Resolver(snapshot.stack).resolve_database(config.cdm_db) if not isinstance(database, ResolvedCDMDatabase): raise TypeError("Groundworkers cdm_db must resolve to a CDM database.") + if database.vocab_connection.dialect_name != "postgresql": + raise ValueError(f"{purpose} require a PostgreSQL CDM database.") primary_engine, vocab_engine = create_cdm_engines( database, register_claims=False ) diff --git a/src/groundworkers/base/database.py b/src/groundworkers/base/database.py new file mode 100644 index 0000000..28f5b96 --- /dev/null +++ b/src/groundworkers/base/database.py @@ -0,0 +1,97 @@ +"""Connection policy for serving existing CDM databases.""" + +from pathlib import Path + +from oa_configurator import ConfigurationError, ResolvedCDMDatabase, ResolvedVectorStore +from omop_alchemy.config import create_cdm_engines +from sqlalchemy.engine import Engine, make_url +from sqlalchemy.exc import NoSuchModuleError + + +def duckdb_file_path(database: str | None) -> Path: + """Use the configured absolute filename without resolving a relative base.""" + if not database or not Path(database).is_absolute(): + raise ConfigurationError( + f"DuckDB database file paths must be absolute, not {database!r}. " + "Set database_name to the full file path without ~." + ) + return Path(database) + + +def same_database_file(first: Path, second: Path) -> bool: + """Compare file identity, retaining path equality for a not-yet-created file.""" + return first.resolve() == second.resolve() or ( + first.is_file() and second.is_file() and first.samefile(second) + ) + + +def validate_duckdb_cdm_paths(resolved: ResolvedCDMDatabase) -> None: + """Require absolute filenames for both configured DuckDB CDM roles.""" + for connection in (resolved.connection, resolved.vocab_connection): + if connection.dialect_name == "duckdb": + duckdb_file_path(make_url(connection.url).database) + + +def validate_duckdb_embedding_file( + store: ResolvedVectorStore, + *, + cdm: ResolvedCDMDatabase | None = None, + allow_create: bool = False, +) -> None: + """Require a real file; only explicit initialization may create a new one.""" + if store.backend_type != "duckdbvss": + return + database = make_url(store.database.connection.url).database + if database in (None, "", ":memory:"): + raise ConfigurationError( + f"DuckDB embeddings require an existing regular file, not {database!r}." + ) + path = duckdb_file_path(database) + if not path.is_file() and not ( + allow_create and not path.exists() and path.parent.is_dir() + ): + raise ConfigurationError( + f"DuckDB embedding file is missing or is not a regular file: {path}" + ) + if cdm is not None and any( + connection.dialect_name == "duckdb" + and same_database_file( + duckdb_file_path(make_url(connection.url).database), path + ) + for connection in (cdm.connection, cdm.vocab_connection) + ): + raise ConfigurationError( + "DuckDB embeddings must use a separate file from the CDM." + ) + + +def create_runtime_cdm_engines(resolved: ResolvedCDMDatabase) -> tuple[Engine, Engine]: + """Open a DuckDB CDM read-only; retain the existing policy for other dialects.""" + connections = (resolved.connection, resolved.vocab_connection) + if not any(connection.dialect_name == "duckdb" for connection in connections): + return create_cdm_engines(resolved, register_claims=False) + if any( + connection.dialect_name == "duckdb" + and make_url(connection.url).database in (None, "", ":memory:") + for connection in connections + ): + raise ValueError( + "DuckDB serving requires an existing CDM file, not an in-memory database." + ) + if not all( + connection.dialect_name == "duckdb" for connection in connections + ) or not ( + resolved.connection.addresses_same_database_as(resolved.vocab_connection) + ): + raise ValueError( + "DuckDB serving requires primary and vocabulary roles in one file." + ) + validate_duckdb_cdm_paths(resolved) + try: + return resolved.create_engines( + register_claims=False, connect_args={"read_only": True} + ) + except (ImportError, NoSuchModuleError) as exc: + raise ValueError( + 'Install "groundworkers[duckdb]" to serve DuckDB files.' + ) from exc diff --git a/src/groundworkers/base/server.py b/src/groundworkers/base/server.py index 4ed2e52..2ff4331 100644 --- a/src/groundworkers/base/server.py +++ b/src/groundworkers/base/server.py @@ -247,12 +247,11 @@ def run( stateless_http=True, ) for tool_name, func in self._tools.items(): - options = {} - if (annotations := self._tool_annotations.get(tool_name)) is not None: - options["annotations"] = annotations - app.tool(name=tool_name, description=inspect.getdoc(func) or "", **options)( - func - ) + app.tool( + name=tool_name, + description=inspect.getdoc(func) or "", + annotations=self._tool_annotations.get(tool_name), + )(func) for prompt_name, prompt in self._prompts.items(): if prompt.arguments is None: app.prompt( @@ -397,7 +396,7 @@ def _advertise_tool_signature( get_origin(value) is Annotated for value in resolved.values() ): signature = inspect.signature(func) - guarded.__signature__ = signature.replace( + advertised_signature = signature.replace( parameters=[ param.replace(annotation=resolved.get(name, param.annotation)) for name, param in signature.parameters.items() @@ -405,6 +404,7 @@ def _advertise_tool_signature( return_annotation=result_model or resolved.get("return", signature.return_annotation), ) + setattr(guarded, "__signature__", advertised_signature) guarded.__annotations__ = resolved if result_model is not None: guarded.__annotations__["return"] = result_model diff --git a/src/groundworkers/base/sql.py b/src/groundworkers/base/sql.py index 5416c31..0465f6b 100644 --- a/src/groundworkers/base/sql.py +++ b/src/groundworkers/base/sql.py @@ -4,8 +4,8 @@ from enum import StrEnum +from sqlalchemy import and_, func, literal from sqlalchemy import column as sql_column -from sqlalchemy import func, literal from sqlalchemy.engine import Connection, Engine __all__ = [ @@ -17,6 +17,7 @@ "normalization_index_expression_sql", "normalization_key_expression", "normalization_key_expression_sql", + "normalization_match_expression", "normalized_key", ] @@ -37,8 +38,18 @@ class NormalizationProfile(StrEnum): # Extending a variant beyond this set requires extending the K expression too. KEY_DROP_TOKENS = frozenset( { - "nos", "nec", "nfs", "unspecified", "unknown", "other", "w", "o", - "wo", "extended", "modified", "release", + "nos", + "nec", + "nfs", + "unspecified", + "unknown", + "other", + "w", + "o", + "wo", + "extended", + "modified", + "release", } ) KEY_DROP_TOKEN_PATTERN = "(?:" + "|".join(sorted(KEY_DROP_TOKENS)) + ")" @@ -49,6 +60,7 @@ def normalization_expression( *, profile: str | NormalizationProfile, remove_stop_phrases: bool, + dialect_name: str = "postgresql", ): """Build the SQL expression used for normalized comparison. @@ -63,15 +75,44 @@ def normalization_expression( globally = _inline("g") expr = func.lower(column) if remove_stop_phrases: - expr = func.regexp_replace( - expr, _inline(STOP_PHRASE_SQL_PATTERN), space, globally - ) + pattern = STOP_PHRASE_SQL_PATTERN + if dialect_name == "duckdb": + pattern = pattern.replace(r"\m", r"\b").replace(r"\M", r"\b") + expr = func.regexp_replace(expr, _inline(pattern), space, globally) expr = func.regexp_replace(expr, _inline(r"[^a-z0-9]+"), space, globally) if profile == "drug_name": expr = func.replace(expr, _inline(" extended release "), space) expr = func.replace(expr, _inline(" modified release "), space) expr = func.regexp_replace(expr, _inline(r"\s+"), space, globally) - return func.btrim(expr) + return func.trim(expr) if dialect_name == "duckdb" else func.btrim(expr) + + +def normalization_match_expression( + column, + normalized_query: str, + *, + profile: str | NormalizationProfile, + remove_stop_phrases: bool, + dialect_name: str, +): + """Compare using native regex syntax; retain PostgreSQL's indexed coarse key.""" + comparison = ( + normalization_expression( + column, + profile=profile, + remove_stop_phrases=remove_stop_phrases, + dialect_name=dialect_name, + ) + == normalized_query + ) + if dialect_name == "duckdb": + # DuckDB's RE2 engine has no lookahead for the PostgreSQL coarse key. + # Scoped scans can compare the exact normalized value directly. + return comparison + return and_( + normalization_key_expression(column) == normalized_key(normalized_query), + comparison, + ) def normalization_index_expression_sql( diff --git a/src/groundworkers/bootstrap.py b/src/groundworkers/bootstrap.py index b343d1d..795909b 100644 --- a/src/groundworkers/bootstrap.py +++ b/src/groundworkers/bootstrap.py @@ -10,9 +10,12 @@ StackConfig, load_stack_config, ) -from omop_alchemy.config import create_cdm_engines from omop_alchemy.cross_database import cdm_sessionmaker +from groundworkers.base.database import ( + create_runtime_cdm_engines, + validate_duckdb_embedding_file, +) from groundworkers.config import ( AppConfig, GroundworkersConfig, @@ -40,10 +43,33 @@ def build_app_config_from_stack(stack: StackConfig) -> AppConfig: raise TypeError( f"Groundworkers cdm_db {groundworkers.cdm_db!r} must reference a CDM database." ) - try: - cdm_engine, cdm_vocab_engine = create_cdm_engines( - cdm_database, register_claims=False + embedding_model = ( + resolver.resolve_model(groundworkers.embedding_model_name) + if groundworkers.embedding_model_name is not None + else None + ) + llm_model = ( + resolver.resolve_model(groundworkers.llm_model_name) + if groundworkers.llm_model_name is not None + else None + ) + vector_store = ( + resolver.resolve_vector_store(groundworkers.vector_store_name) + if groundworkers.vector_store_name is not None + else None + ) + if vector_store is not None: + validate_duckdb_embedding_file(vector_store, cdm=cdm_database) + + knowledge_root = None + if groundworkers.knowledge_packs_root is not None: + knowledge_root = _resolve_path( + groundworkers.knowledge_packs_root, + stack.loaded_path, ) + + try: + cdm_engine, cdm_vocab_engine = create_runtime_cdm_engines(cdm_database) except Exception as exc: detail = " ".join(str(exc).splitlines()) for role in (Role.PRIMARY, Role.VOCAB): @@ -67,29 +93,6 @@ def build_app_config_from_stack(stack: StackConfig) -> AppConfig: vocab=cdm_vocab_engine, ) - embedding_model = ( - resolver.resolve_model(groundworkers.embedding_model_name) - if groundworkers.embedding_model_name is not None - else None - ) - llm_model = ( - resolver.resolve_model(groundworkers.llm_model_name) - if groundworkers.llm_model_name is not None - else None - ) - vector_store = ( - resolver.resolve_vector_store(groundworkers.vector_store_name) - if groundworkers.vector_store_name is not None - else None - ) - - knowledge_root = None - if groundworkers.knowledge_packs_root is not None: - knowledge_root = _resolve_path( - groundworkers.knowledge_packs_root, - stack.loaded_path, - ) - return AppConfig( stack=stack, groundworkers=groundworkers, diff --git a/src/groundworkers/config/README b/src/groundworkers/config/README deleted file mode 100644 index b453644..0000000 --- a/src/groundworkers/config/README +++ /dev/null @@ -1,6 +0,0 @@ -These files are copies of omop-graph's `config/` predicate CSVs. - -omop-graph keeps its copies at the repository root, outside `src/omop_graph/`, so hatchling does not include them in its wheel. Packaging them here lets `omop-graph relationship-classification` run from an installed Groundworkers environment. - -The setup console uses these files as the default source for **Prepare graph**; -callers can still provide another directory when needed. diff --git a/src/groundworkers/config/predicate_classification.csv b/src/groundworkers/config/predicate_classification.csv deleted file mode 100644 index 6600d04..0000000 --- a/src/groundworkers/config/predicate_classification.csv +++ /dev/null @@ -1,26 +0,0 @@ -class,subclass,description,semantics,inference -Identity,Exact equivalence,Semantically identical concepts across vocabularies,Symmetric merge,Interchangeable -Identity,Near equivalence,Context-specific similarity (e.g. Brand names),Weak mapping,Proximity search -Identity,Version lineage,Concept lifecycle and deprecation history,Temporal audit,Succession -Hierarchy,Taxonomic – up,Generalization to parent concept,Inheritance,Ancestor traversal -Hierarchy,Taxonomic – down,Specialization to descendant concept,Refinement,Descendant traversal -Hierarchy,Categorical – up,Membership in a classification group,Grouping,Class membership -Hierarchy,Categorical – down,Expansion of grouping to members,Member listing,Class expansion -Composition,Physical,Material part-whole or assembly,Physical decomposition,Part-of inference -Composition,Structural,Inherent chemical/structural definition,Potency/Recipe definition,Structural reasoning -Composition,Containment,Packaging or multi-item kit grouping,Physical enclosure,Container reasoning -Composition,Logical,Conceptual assembly (e.g. Question/Answer sets),Logical decomposition,Knowledge structure -Association,Causal,Pathophysiological causation/etiology,Etiology search,Root cause analysis -Association,Therapeutic,Relationship between intervention and indication,Clinical guideline,Treatment inference -Association,Interaction,Biological or chemical interference between agents,Safety check,Interaction reasoning -Association,Mechanistic,Mechanism of Action or functional pathways,Pathway analysis,Mechanistic inference -Association,Diagnostic,Evidence linking tests/procedures to findings,Diagnostic path,Evidence tracing -Association,Administrative,"Operational, billing, or system tracking links",ETL/Reporting,System mapping -Association,Mapping,Cross-vocabulary or historical mapping without a guaranteed 1:1 correspondence,Weak mapping,Low-confidence linking -Attribute,Clinical,"Qualifiers like severity, status, or course",Phenotyping,Attribute enrichment -Attribute,Anatomical,Spatial location or morphology,Spatial localization,Anatomical qualification -Attribute,Measurement,"Quantitative units, scales, and properties",Quantitative analysis,Scale interpretation -Attribute,Pharmaceutical,Dose form and delivery characteristics,Drug characterization,Formulation matching -Attribute,Temporal,"Clinical event timing, sequence, or duration",Timeline reconstruction,Temporal logic -Attribute,Procedural,"Technical execution (method, route, access)",Process qualification,Technique specification -Attribute,Context,General situational qualifiers (status/state),Contextual filtering,Contextual qualification diff --git a/src/groundworkers/config/predicate_mapping.csv b/src/groundworkers/config/predicate_mapping.csv deleted file mode 100644 index 65f3b7c..0000000 --- a/src/groundworkers/config/predicate_mapping.csv +++ /dev/null @@ -1,735 +0,0 @@ -class,subclass,r_id,r_name,classification_notes,additional_info,link -Association,Causal,Caused by,Caused by (SNOMED),"Relates conditions to their biological, pathological, or environmental causes.",, -Association,Causal,Causes,Causes (SNOMED),"Relates conditions to their biological, pathological, or environmental causes.",, -Association,Causal,Etiology of,Etiology of (SNOMED),"Relates conditions to their biological, pathological, or environmental causes.",, -Association,Diagnostic,Diagnosed through,Diagnosed through (NDF-RT),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Alkalosis] -> [NITRAZINE (pH) TEST STRIP]",, -Association,Diagnostic,May diagnose,May diagnose (NDF-RT),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [ASSURE PRISM MULTI (GLUCOSE) TEST STRIP] -> [Diabetes Mellitus]",, -Association,Interaction,Drug-drug inter for,Drug-drug interaction for (NDF),Defines safety or therapeutic interference between agents.,, -Association,Interaction,Has drug-drug inter,Is involved in drug-drug interaction (NDF),Defines safety or therapeutic interference between agents.,, -Association,Mechanistic,CI MoA of,Contraindicating mechanism of action of (NDF-RT),"Links substances to functional biological pathways or mechanisms. Example: [Estrogen Receptor Agonists] -> [NEVIRAPINE 200MG TAB,UD]",, -Association,Therapeutic,Has CI MoA,Has contraindicating mechanism of action (NDF-RT),"Links therapeutic interventions to clinical indications or contraindications. Example: [SUMATRIPTAN SUCCINATE 3MG/0.5ML INJ,PEN,0.5ML] -> [Monoamine Oxidase Inhibitors]",, -Association,Mechanistic,Has MoA,Has mechanism of action (NDF-RT),"Links substances to functional biological pathways or mechanisms. Example: [HYDROGEN PEROXIDE 3% SPRAY,TOP] -> [Unknown Cellular or Molecular Interaction]",, -Association,Causal,Induced by,Induced by (NDF-RT),"Relates conditions to their biological, pathological, or environmental causes. Example: [Abortion, Spontaneous] -> [NORGESTREL 0.075MG TAB]",, -Association,Causal,Induces,Induces (NDF-RT),"Relates conditions to their biological, pathological, or environmental causes. Example: [ASCORBIC ACID 1GM TAB,UD] -> [Hyperoxaluria]",, -Association,Mechanistic,MoA of,Mechanism of action of (NDF-RT),"Links substances to functional biological pathways or mechanisms. Example: [Interleukin-12 Antagonists] -> [USTEKINUMAB 5MG/ML INJ,SOLN,26ML]",, -Association,Therapeutic,May be treated by,May be treated by (NDF-RT),"Links therapeutic interventions to clinical indications or contraindications. Example: [Tardive Dyskinesia] -> [VALBENAZINE 80MG CAP,ORAL]",, -Association,Therapeutic,May treat,May treat (NDF-RT),"Links therapeutic interventions to clinical indications or contraindications. Example: [HYDROGEN PEROXIDE 3% SPRAY,TOP] -> [Surgical Wound Infection]",, -Attribute,Temporal,Dev intended site of,Device intended site of (SNOMED),"Qualifies clinical events by their timing, sequence, or duration. Example: [Structure of lower thoracic esophagus] -> [Lower esophageal electrical stimulation system lead]",Represents the site where the device is intended to reside in or on the body.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/physical-object/physical-object-defining-attributes#has-device-intended-site -Attribute,Anatomical,Dir morph of,Direct morphology of (SNOMED),"Defines a physical/structural abnormality at the cellular or tissue level. Example: [Benign neurofibroma] -> [Excision of neurofibroma, extensive]",Direct morphology (attribute) describes the morphologically abnormal structure that is the direct object of the Method action.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#direct-morphology -Attribute,Anatomical,Dir proc site of,Direct procedure site of (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Structure of part of left hand] -> [Partial amputation of left hand]","is used when the action of the procedure is directly aimed at anatomical or acquired body structure or site, rather than something else located there",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#procedure-site-direct -Attribute,Anatomical,Direct site of,Direct site of (SNOMED),"Spatial qualifier describing location, morphology, or extent. Example: [Tissue specimen from penis obtained by excision] -> [Surgical margin involved by noninvasive premalignant neoplasm of penis in excised tissue specimen]",, -Attribute,Anatomical,Finding site of,Finding site of (SNOMED),Links diagnostic evidence or tests to a clinical concept/state. Example: [Entire right fibula] -> [Longitudinal deficiency of bilateral fibulae],Specifies the body site affected by a condition.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#finding-site -Attribute,Anatomical,Has asso morph,Has associated morphology (SNOMED),Defines a physical/structural abnormality at the cellular or tissue level. Example: [Neoplasm of uncertain behavior of larynx] -> [Neoplasm of uncertain behavior],specifies the morphologic changes seen at the tissue or cellular level that are characteristic of a disease.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#associated-morphology -Association,Administrative,Has asso visit,Has associated visit (OMOP),"Operational link for system billing, metadata, dictionaries, or encounter tracking. Example: [Remote physiologic monitoring treatment management services, clinical staff/physician/other qualified health care professional time in a calendar month requiring interactive communication with the patient/caregiver during the month; each additional 20...] -> [Telehealth Provided Other than in Patient’s Home]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-HCPCS#hcpcs-to-visit-relationships -Attribute,Temporal,Has dev intend site,Has device intended site (SNOMED),"Qualifies clinical events by their timing, sequence, or duration. Example: [Neonatal indwelling urethral drainage catheter] -> [Structure of genitourinary system]",Represents the site where the device is intended to reside in or on the body.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/physical-object/physical-object-defining-attributes#has-device-intended-site -Attribute,Anatomical,Has dir morph,Has direct morphology (SNOMED),Defines a physical/structural abnormality at the cellular or tissue level. Example: [Primary open reduction of fracture of short bone and fixation using screw] -> [Fracture],Direct morphology (attribute) describes the morphologically abnormal structure that is the direct object of the Method action.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#direct-morphology -Attribute,Anatomical,Has dir proc site,Has direct procedure site (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Tc-99 MIBI (technetium 99m methoxyisobutylisonitrile) parathyroid subtraction study] -> [Parathyroid structure]","is used when the action of the procedure is directly aimed at anatomical or acquired body structure or site, rather than something else located there",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#procedure-site-direct -Attribute,Anatomical,Has direct site,Has direct site (SNOMED),"Spatial qualifier describing location, morphology, or extent. Example: [Length of primary malignant neoplasm of prostate in resected prostate specimen] -> [Resected prostate specimen]",, -Attribute,Anatomical,Has finding site,Has finding site (SNOMED),Links diagnostic evidence or tests to a clinical concept/state. Example: [Neoplasm of uncertain behavior of larynx] -> [Laryngeal structure],,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#finding-site -Attribute,Anatomical,Has indir morph,Has indirect morphology (SNOMED),Defines a physical/structural abnormality at the cellular or tissue level. Example: [Drainage of abscess using computed tomography guidance] -> [Abscess morphology],, -Attribute,Anatomical,Has indir proc site,Has indirect procedure site (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Total removal of prosthetic material from previous repair of vaginal prolapse] -> [Vaginal structure]",, -Attribute,Temporal,Has intended site,Has dose form intended site (SNOMED),"Qualifies clinical events by their timing, sequence, or duration. Example: [Conventional release oral pellets in capsule] -> [Oral]",, -Attribute,Anatomical,Has morphology,Has Morphology (SNOMED),Defines a physical/structural abnormality at the cellular or tissue level.,, -Attribute,Anatomical,Has proc morph,Has procedure morphology (SNOMED),Defines a physical/structural abnormality at the cellular or tissue level. Example: [Endovenous mechanochemical ablation of varicose vein of lower limb] -> [Varix],"Procedure morphology (attribute) is used to specify the morphology, or abnormal structure, involved in a procedure. It is used when defining general concepts that subsume direct and indirect morphology. It subsumes the more specific attributes, Direct and Indirect Morphology. These should be used, if possible. - -",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#procedure-morphology -Attribute,Anatomical,Has proc site,Has procedure site (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Introduction of vaginal tape] -> [Vaginal structure]",, -Attribute,Anatomical,Has specimen morph,Has specimen source morphology (SNOMED),Defines a physical/structural abnormality at the cellular or tissue level. Example: [Swab from bite wound] -> [Bite wound],Specifies the morphologic abnormality from which a specimen is obtained.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/specimen/specimen-defining-attributes#specimen-source-morphology -Attribute,Anatomical,Indir morph of,Indirect morphology of (SNOMED),Defines a physical/structural abnormality at the cellular or tissue level. Example: [Cyst] -> [Aspiration of cyst of thyroid using ultrasonographic guidance],, -Attribute,Anatomical,Indir proc site of,Indirect procedure site of (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Vertebral column region] -> [Insertion of neurostimulator electrodes into the spinal cord]","specifies the anatomical location but is not the direct focus of the procedure. The direct object of the action may be a device, a substance, or a morphologic abnormality that is not a part of the tissue structure of the anatomical site in which it is located, such as a calculus, thrombus, or foreign body.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#procedure-site-indirect -Attribute,Temporal,Intended site of,Dose form intended site of (SNOMED),"Qualifies clinical events by their timing, sequence, or duration. Example: [Intravitreal] -> [Prolonged-release intravitreal implant]",, -Attribute,Anatomical,Morphology of,Morphology of (SNOMED),Defines a physical/structural abnormality at the cellular or tissue level.,, -Attribute,Anatomical,Proc morph of,Procedure morphology of (SNOMED),Defines a physical/structural abnormality at the cellular or tissue level. Example: [Cyst] -> [Operation on cyst of epididymis],"Procedure morphology (attribute) is used to specify the morphology, or abnormal structure, involved in a procedure. It is used when defining general concepts that subsume direct and indirect morphology. It subsumes the more specific attributes, Direct and Indirect Morphology. These should be used, if possible. - -",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#procedure-morphology -Attribute,Anatomical,Proc site of,Procedure site of (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Vertebral column region] -> [Implantation of intrathecal drug delivery device adjacent to spinal cord]",, -Association,Administrative,Question source of,Question source of,"Operational link for system billing, metadata, dictionaries, or encounter tracking. Example: [RAND American Life Panel] -> [Do you have a child(ren) that are currently enrolled in childcare centers, schools, or colleges?]",, -Attribute,Anatomical,Specimen morph of,Specimen source morphology of (SNOMED),Defines a physical/structural abnormality at the cellular or tissue level. Example: [Peritoneal dialysis catheter insertion site] -> [Body fluid specimen from peritoneal dialysis insertion site],Specifies the morphologic abnormality from which a specimen is obtained.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/specimen/specimen-defining-attributes#specimen-source-morphology -Attribute,Anatomical,Has laterality,Has laterality (SNOMED),"Spatial qualifier describing location, morphology, or extent. Example: [Structure of flexor A4 pulley of index finger] -> [Side]",, -Attribute,Clinical,Has severity,Has severity (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Recurrent severe manic episodes] -> [Severe]",,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#severity -Attribute,Clinical,Has stage,Has Stage (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Chronic kidney disease stage V] -> [Stage level 5]",, -Attribute,Anatomical,Laterality of,Laterality of (SNOMED),"Spatial qualifier describing location, morphology, or extent. Example: [Unilateral] -> [Harlequin syndrome]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Attribute,Clinical,Severity of,Severity of (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Mild] -> [Mild thinness in adults]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Attribute,Clinical,Stage of,Stage of (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Stage level 2] -> [Chronic kidney disease stage II]",, -Attribute,Context,Context of,Context of (HemOnc),Specific situational modifier or contextual role. Example: [Perioperative therapy] -> [Capecitabine and Cisplatin (CX)],, -Attribute,Context,Finding context of,Finding context of (SNOMED),Contextual modifier for a clinical finding. Example: [Known] -> [Family grieving],, -Attribute,Context,Has context,Has context (HemOnc),Specific situational modifier or contextual role. Example: [AC-D] -> [Neoadjuvant therapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Context,Has finding context,Has finding context (SNOMED),Contextual modifier for a clinical finding. Example: [Elevated blood-pressure reading without diagnosis of hypertension] -> [Known present],, -Attribute,Context,Has proc context,Has procedure context (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Counselled by health visitor] -> [Done]",, -Attribute,Context,Has relat context,Has subject relationship context (SNOMED),Specific situational modifier or contextual role. Example: [Elevated blood-pressure reading without diagnosis of hypertension] -> [Subject of record],, -Attribute,Temporal,Has temporal context,Has temporal context (SNOMED),"Qualifies clinical events by their timing, sequence, or duration. Example: [Elevated blood-pressure reading without diagnosis of hypertension] -> [Current]",, -Attribute,Context,Proc context of,Procedure context of (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Recommended] -> [Hepatitis B immunization recommended]",, -Attribute,Context,Relat context of,Subject relationship context of (SNOMED),Specific situational modifier or contextual role. Example: [Spouse of subject] -> [Spouse unwell],, -Attribute,Temporal,Temporal context of,Temporal context of (SNOMED),"Qualifies clinical events by their timing, sequence, or duration. Example: [Current or past] -> [3.5 year examination not wanted]",, -Attribute,Measurement,Has Scale,Has Scale,"Defines metrics, scales, units, and quantitative properties of an observation. Example: [Oral Health] -> [In the course of your current illness or its treatment, not only the past week:]",, -Attribute,Measurement,Has unit,Has unit,"Defines metrics, scales, units, and quantitative properties of an observation. Example: [Cranial rhythmic impulse] -> [Cycles/minute]",, -Attribute,Measurement,Scale of,Scale of,"Defines metrics, scales, units, and quantitative properties of an observation. Example: [Doctor(s), Nurse(s), Psychologist(s), Radiotherapy technician(s), Other (please specify: _____________)] -> [QLQ-COMU26 Communication module]",, -Attribute,Measurement,Unit of,Unit of,"Defines metrics, scales, units, and quantitative properties of an observation. Example: [gram] -> [Tumor Weight]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Attribute,Pharmaceutical,Dose form of,Dose form of (SNOMED),Qualifies physical drug delivery properties or dose forms. Example: [Powder for oral and rectal suspension] -> [Calcium polystyrene sulfonate 999.34 mg/g powder for oral and rectal suspension],,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/pharmaceutical-and-biologic-product/index/index/pharmaceutical-dose-form-naming-and-modeling-conventions -Attribute,Pharmaceutical,Has dose form,Has dose form (SNOMED),Qualifies physical drug delivery properties or dose forms. Example: [Calcium oxybate 500 mg/mL and magnesium oxybate 500 mg/mL and potassium oxybate 500 mg/mL and sodium oxybate 500 mg/mL oral solution] -> [Oral solution],,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/pharmaceutical-and-biologic-product/index/index/pharmaceutical-dose-form-naming-and-modeling-conventions -Attribute,Procedural,Has route,Has route (DM+D),"Defines execution technicalities like route of administration, method, or device used. Example: [Brexucabtagene autoleucel] -> [Intravenous]",, -Attribute,Procedural,Has route of admin,Has route of administration (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Intravitreal injection of anti-vascular endothelial growth factor] -> [Intravitreal]",, -Composition,Structural,Has strength,Has ingredient qualitative strength (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Hepatitis B high dose recombinant surface antigen only vaccine product] -> [High dose],, -Attribute,Procedural,Route of,Route of (DM+D),"Defines execution technicalities like route of administration, method, or device used. Example: [Oral] -> [Levothyroxine sodium 100 microgram oral tablet]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Attribute,Procedural,Route of admin of,Route of administration of (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Injectable] -> [Auto-Injector]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Composition,Structural,Strength of,Ingredient qualitative strength of (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Low dose] -> [Acellular Bordetella pertussis and Clostridium tetani and low dose Corynebacterium diphtheriae and inactivated Human poliovirus antigens only vaccine product],, -Composition,Containment,Box of,Prepackaged box of (OMOP),Links items to their physical packaging or multi-item kit groupings. Example: [Calcium 0.0753 MG/MG / Charcoal 0.0753 MG/MG / Kaolin 0.04 MG/MG / Magnesium 0.005 MG/MG Oral Powder [Aruto] Box of 1] -> [Calcium 0.0753 MG/MG / Charcoal 0.0753 MG/MG / Kaolin 0.04 MG/MG / Magnesium 0.005 MG/MG Oral Powder [Aruto]],, -Composition,Physical,Contained in,Is contained in (RxNorm),Physical material assembly or structural part-whole relationship. Example: [Omeprazole 20 MG Extended Release Oral Capsule] -> [{28 (amoxicillin 500 MG Oral Capsule) / 14 (clarithromycin 500 MG Oral Tablet) / 14 (Omeprazole 20 MG Extended Release Oral Capsule) } Pack [Klacid]],Links contents to their parent Pack/Kit.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Composition,Physical,Contains,Contains (RxNorm),Physical material assembly or structural part-whole relationship. Example: [{24 (fexinidazole 600 MG Oral Tablet) } Pack] -> [fexinidazole 600 MG Oral Tablet],Links a Pack/Kit to its contents.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Composition,Containment,Is pack of,Is pack of (SNOMED),Links items to their physical packaging or multi-item kit groupings.,, -Composition,Physical,Meas component of,Measured Component of (SNOMED),Physical material assembly or structural part-whole relationship.,, -Composition,Physical,Panel contains,Panel contains,Physical material assembly or structural part-whole relationship. Example: [EORTC Quality of Life Questionnaire С30] -> [EORTC Quality of Life Questionnaire: Have you vomited during the past week?],, -Composition,Structural,Active ing of,Active ingredient of (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Podophyllotoxin and/or podophyllotoxin derivative] -> [Podophyllotoxin and/or podophyllotoxin derivative-containing product],A medicinal product concept has a set of active ingredient substance(s) responsible for providing the therapeutic effect of the medicinal product and which are described using the clinically relevant part or whole of the substance that is intended to have a therapeutic action on or within the body,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/pharmaceutical-and-biologic-product/pharmaceutical-and-biologic-product-and-dose-form-attributes-summary/ingredient-substance-attributes#has-active-ingredient -Composition,Structural,Has active ing,Has active ingredient (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Calcium oxybate- and magnesium oxybate- and potassium oxybate- and sodium oxybate-containing product] -> [Potassium oxybate],A medicinal product concept has a set of active ingredient substance(s) responsible for providing the therapeutic effect of the medicinal product and which are described using the clinically relevant part or whole of the substance that is intended to have a therapeutic action on or within the body,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/pharmaceutical-and-biologic-product/pharmaceutical-and-biologic-product-and-dose-form-attributes-summary/ingredient-substance-attributes#has-active-ingredient -Composition,Structural,Has prec ingredient,Has precise active ingredient (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Calcium oxybate 500 mg/mL and magnesium oxybate 500 mg/mL and potassium oxybate 500 mg/mL and sodium oxybate 500 mg/mL oral solution] -> [Calcium oxybate],, -Composition,Structural,Has precise ing,Has precise ingredient (RxNorm),"Defines the inherent chemical constituents or dosage strength. Example: [immunoglobulin G, human 150 MG/ML] -> [immunoglobulin G, human]",Precise version of the ingredient link (e.g. base vs salt).,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Composition,Structural,Has spec active ing,Has specific active ingredient (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Darunavir 600 mg oral tablet] -> [Darunavir ethanolate],, -Composition,Structural,Has standard ing,Has standard ingredient (OMOP),Defines the inherent chemical constituents or dosage strength.,, -Composition,Structural,Is standard ing of,Is standard ingredient of ingredient (OMOP),Defines the inherent chemical constituents or dosage strength.,, -Composition,Structural,NDFRT has ing,Has ingredient (NDF-RT),"Defines the inherent chemical constituents or dosage strength. Example: [HYDROGEN PEROXIDE 3% SPRAY,TOP] -> [Hydrogen Peroxide]",, -Composition,Structural,NDFRT ing of,Ingredient of (NDF-RT),Defines the inherent chemical constituents or dosage strength. Example: [Salmeterol Xinafoate] -> [SALMETEROL XINAFOATE],, -Composition,Structural,Prec ingredient of,Precise active ingredient of (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Cabotegravir] -> [Cabotegravir 200 mg/mL prolonged-release suspension for injection],, -Composition,Structural,Precise ing of,Precise ingredient of (RxNorm),"Defines the inherent chemical constituents or dosage strength. Example: [tetanus immune globulin, human] -> [tetanus immune globulin, human 250 UNT/ML]",, -Composition,Structural,RxNorm has ing,Has ingredient (RxNorm),Defines the inherent chemical constituents or dosage strength. Example: [hepatitis B immune globulin 217 UNT/ML] -> [hepatitis B immune globulin],Links a drug concept to its constituent active ingredient(s).,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Composition,Structural,RxNorm ing of,Ingredient of (RxNorm),Defines the inherent chemical constituents or dosage strength. Example: [Umifenovir] -> [Umifenovir Oral Granules],Links an ingredient to its containing drug concepts.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Composition,Structural,Spec active ing of,Specific active ingredient of (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Darunavir ethanolate] -> [Darunavir 600 mg oral tablet],, -Composition,Physical,Component of,Component of,Physical material assembly or structural part-whole relationship. Example: [Malignant teratoma] -> [Percent of malignant teratoma in primary malignant mixed germ cell tumor of testis],, -Composition,Physical,Has component,Has component,Physical material assembly or structural part-whole relationship. Example: [Abnormal lymphocyte count] -> [Lymphocyte],, -Hierarchy,Categorical – up,Category of,Category of,Assigns a concept to a high-level grouping or classification chapter. Example: [Abdominal composition] -> [Weight-to-muscle ratio],, -Hierarchy,Categorical – up,Drug class of drug,Drug class of drug (OMOP),Assigns a concept to a high-level grouping or classification chapter. Example: [propylene glycol] -> [Propylene glycol Topical Solution],,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Hierarchy,Categorical – up,Therap class of,Therapeutic class of (NDF),Assigns a concept to a high-level grouping or classification chapter. Example: [Antihelminthic Agent] -> [ALBENDAZOLE],, -Hierarchy,Categorical – up,membership inference,Drug has drug class (OMOP),Assigns a concept to a high-level grouping or classification chapter.,,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Hierarchy,Categorical – up,membership inference,Has Category,Assigns a concept to a high-level grouping or classification chapter.,, -Hierarchy,Categorical – up,membership inference,Has therapeutic class (NDF),Assigns a concept to a high-level grouping or classification chapter.,, -Hierarchy,Taxonomic – down,RxNorm inverse is a,Inverse is a (RxNorm),Refinement from a broad parent to a specific child. Example: [AESCULUS HIPPOCASTANUM SEED OIL / cupric acetate / Epinephrine / Ergot Alkaloids / Helleborus niger whole extract / Potentilla anserina whole extract / tobacco leaf extract Oral Solution] -> [AESCULUS HIPPOCASTANUM SEED OIL 2 X / cupric acetate 6 X / Epinephrine 6 X / Ergot Alkaloids 4 X / Helleborus niger whole extract 6 X / Potentilla anserina whole extract 2 X / tobacco leaf extract 4 X Oral Solution],,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Hierarchy,Taxonomic – down,Subsumes,Subsumes,Refinement from a broad parent to a specific child. Example: [Medicare] -> [Medicare Hospice],,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Hierarchy,Taxonomic – up,Is a,Is a,Inheritance link generalizing to a broader parent. Example: [Medicare Managed Care] -> [Medicare],, -Hierarchy,Taxonomic – up,RxNorm is a,Is a (RxNorm),Inheritance link generalizing to a broader parent. Example: [hepatitis B immune globulin 200 UNT/ML Injectable Solution] -> [hepatitis B immune globulin Injectable Solution],,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Identity,Exact equivalence,Concept same_as from,Active same_as inactive (SNOMED),Direct semantic identity or mapping rule across different vocabularies.,, -Identity,Exact equivalence,Concept same_as to,Inactive same_as active (SNOMED),Direct semantic identity or mapping rule across different vocabularies.,, -Identity,Exact equivalence,CPT4 - CVX eq,CPT4 to CVX equivalent (CVX),"Explicitly tagged as equivalent, representing exact semantic identity.",, -Identity,Exact equivalence,CPT4 - LOINC eq,CPT4 to LOINC equivalent (SNOMED),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [Basic metabolic panel (Calcium, total) This panel must include the following: Calcium, total (82310) Carbon dioxide (bicarbonate) (82374) Chloride (82435) Creatinine (82565) Glucose (82947) Potassium (84132) Sodium (84295) Urea nitrogen (BUN) (84520)] -> [Basic metabolic 2000 panel - Serum or Plasma]",, -Identity,Exact equivalence,CPT4 - SNOMED eq,CPT-4 to SNOMED equivalent (OMOP),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [Pelvic examination (List separately in addition to code for primary procedure)] -> [Manual pelvic examination]",, -Identity,Exact equivalence,CVX - CPT4 eq,CVX to CPT4 equivalent (CVX),"Explicitly tagged as equivalent, representing exact semantic identity.",, -Identity,Exact equivalence,DRG - MS-DRG eq,DRG to MS-DRG equivalent (CMS),"Explicitly tagged as equivalent, representing exact semantic identity.",, -Identity,Exact equivalence,ICD9P - SNOMED eq,ICD-9-Procedure to SNOMED equivalent (OMOP),"Explicitly tagged as equivalent, representing exact semantic identity.",, -Identity,Exact equivalence,ICDO to OncoTree eq,ICDO to OncoTree equivalent,"Explicitly tagged as equivalent, representing exact semantic identity. Example: [Clear cell borderline tumor of ovary] -> [Clear Cell Borderline Ovarian Tumor (CCBOV)]",, -Identity,Exact equivalence,LOINC - CPT4 eq,LOINC to CPT4 equivalent (SNOMED),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [Asparagus IgE Ab [Units/volume] in Serum] -> [Allergen specific IgE; quantitative or semiquantitative, crude allergen extract, each]",, -Identity,Exact equivalence,LOINC - SNOMED eq,LOINC Hierarchy to SNOMED equivalent (LOINC),"Explicitly tagged as equivalent, representing exact semantic identity.",, -Identity,Exact equivalence,MedDRA - SNOMED eq,MedDRA to SNOMED equivalent (OMOP),"Explicitly tagged as equivalent, representing exact semantic identity.",, -Identity,Exact equivalence,MS-DRG - DRG eq,MS-DRG to DRG equivalent (CMS),"Explicitly tagged as equivalent, representing exact semantic identity.",, -Identity,Exact equivalence,NDFRT - RxNorm eq,NDF-RT to RxNorm equivalent (RxNorm),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [Vaginal Film] -> [Vaginal Film]",, -Identity,Exact equivalence,NDFRT - SNOMED eq,NDF-RT to SNOMED equivalent (RxNorm),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [Antigens, CD99] -> [Lymphocyte antigen CD99]",, -Identity,Exact equivalence,NDFRT to ATC eq,NDFRT to ATC equivalent (NDF-RT),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [Cholera Vaccines] -> [Cholera vaccines]",Is a cross-link between ATC Classes and National Drug File - Reference Terminology.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-ATC#other-information -Identity,Exact equivalence,NDFRT to VA Class eq,NDFRT to VA Class equivalent (NDF-RT),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [Immune Sera] -> [IMMUNE SERUMS]",, -Identity,Exact equivalence,Nebraska-CAP eq,Nebraska Lexicon to CAP equivalent,"Explicitly tagged as equivalent, representing exact semantic identity.",, -Identity,Exact equivalence,OncoTree to ICDO eq,OncoTree to ICDO equivalent,"Explicitly tagged as equivalent, representing exact semantic identity. Example: [Soft Tissue] -> [Neoplasm defined only by topography: Connective, Subcutaneous and other soft tissues, NOS]",, -Identity,Exact equivalence,Prep to Chem eq,Pharmaceutical Preparation to Chemical Structure equivalent (NDF-RT),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [BUTTER] -> [Butter]",, -Identity,Exact equivalence,RxNorm - NDFRT eq,RxNorm to NDF-RT equivalent (RxNorm),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [hepatitis B immune globulin] -> [hepatitis B hyperimmune globulin]",, -Identity,Exact equivalence,RxNorm - SNOMED eq,RxNorm to SNOMED equivalent (RxNorm),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [hepatitis B immune globulin] -> [Hepatitis B surface antigen immunoglobulin-containing product]",, -Identity,Exact equivalence,RxNorm - Source eq,RxNorm to Drug Source equivalent (OMOP),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [hepatitis B immune globulin] -> [Hepatitis B Immune Globulin Human]",, -Identity,Exact equivalence,RxNorm - VAProd eq,RxNorm to VA Product equivalent (NDF-RT),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [0.5 ML hepatitis B immune globulin 217 UNT/ML Prefilled Syringe [Bayhep B]] -> [HEPATITIS B IMMUNE GLOBULIN (BAYHEP) INJ,SYR,0.5ML]",, -Identity,Exact equivalence,SNOMED - ATC eq,SNOMED to ATC equivalent (RxNorm),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [COVID-19 vaccine] -> [Covid-19 vaccines]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Identity,Exact equivalence,SNOMED - CPT4 eq,SNOMED to CPT-4 equivalent (OMOP),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [Hospital admission] -> [Induced abortion, by 1 or more vaginal suppositories (eg, prostaglandin) with or without cervical dilation (eg, laminaria), including hospital admission and visits, delivery of fetus and secundines; with hysterotomy (failed medical evacuation)]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Identity,Exact equivalence,SNOMED - ICD9P eq,SNOMED to ICD-9-Procedure equivalent (OMOP),"Explicitly tagged as equivalent, representing exact semantic identity.",, -Identity,Exact equivalence,SNOMED - LOINC eq,SNOMED to LOINC Hierarchy equivalent (LOINC),"Explicitly tagged as equivalent, representing exact semantic identity.",, -Identity,Exact equivalence,SNOMED - MedDRA eq,SNOMED to MedDRA equivalent (OMOP),"Explicitly tagged as equivalent, representing exact semantic identity.",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-SNOMED#equivalent-and-categorical-relationships -Identity,Exact equivalence,SNOMED - NDFRT eq,SNOMED to NDF-RT equivalent (RxNorm),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [Leukocyte elastase] -> [Leukocyte Elastase]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-SNOMED#equivalent-and-categorical-relationships -Identity,Exact equivalence,SNOMED - RxNorm eq,SNOMED to RxNorm equivalent (RxNorm),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [Calcium oxybate 500 mg/mL and magnesium oxybate 500 mg/mL and potassium oxybate 500 mg/mL and sodium oxybate 500 mg/mL oral solution] -> [calcium oxybate 234 MG/ML / magnesium oxybate 96 MG/ML / potassium oxybate 130 MG/ML / sodium oxybate 40 MG/ML Oral Solution]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-SNOMED#equivalent-and-categorical-relationships -Identity,Exact equivalence,SNOMED - VA Class eq,SNOMED to VA Class equivalent (RxNorm),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [Immunoglobulin] -> [IMMUNOGLOBULINS]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-SNOMED#equivalent-and-categorical-relationships -Identity,Exact equivalence,Source - RxNorm eq,Drug Source to RxNorm equivalent (OMOP),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [Fibryga] -> [Fibryga]",, -Identity,Exact equivalence,VA Class - SNOMED eq,VA Class to SNOMED equivalent (RxNorm),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [ANTIHISTAMINES] -> [Histamine receptor antagonist]",, -Identity,Exact equivalence,VA Class to ATC eq,VA Class to ATC equivalent (NDF-RT),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [CEPHALOSPORIN 3RD GENERATION] -> [Third-generation cephalosporins]",Is a cross-link between ATC and Veteran Affairs drug classification.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-ATC#other-information -Identity,Exact equivalence,VA Class to NDFRT eq,VA Class to NDFRT equivalent (NDF-RT),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [ANTINEOPLASTICS] -> [Antineoplastic Agent]",, -Identity,Exact equivalence,VAProd - RxNorm eq,VA Product to RxNorm equivalent (NDF-RT),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [HYDROGEN PEROXIDE 3% SPRAY,TOP] -> [hydrogen peroxide 30 MG/ML Topical Spray]",, -Association,Mapping,Concept alt_to from,Active alternative_to inactive (SNOMED),"This is SNOMED's ALTERNATIVE inactivation association, the same family as possibly_equivalent_to and was_a. SNOMED's own documentation describes it as one of several acceptable alternative concepts, where the actual choice must be decided by a human. It is not a safe automatic identity hop.",, -Association,Mapping,Concept alt_to to,Inactive alternative_to active (SNOMED),"This is SNOMED's ALTERNATIVE inactivation association, the same family as possibly_equivalent_to and was_a. SNOMED's own documentation describes it as one of several acceptable alternative concepts, where the actual choice must be decided by a human. It is not a safe automatic identity hop.",, -Association,Mapping,Concept poss_eq from,Active possibly_equivalent_to inactive (SNOMED),"SNOMED's loose, many-to-many fallback for retired concepts with no clean 1:1 successor. It is not a true equivalence, and not an administrative or billing link either; see linked known-issues page.",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Known-Issues-in-Vocabularies#snomed -Association,Mapping,Concept poss_eq to,Inactive possibly_equivalent_to active (SNOMED),"SNOMED's loose, many-to-many fallback for retired concepts with no clean 1:1 successor. It is not a true equivalence, and not an administrative or billing link either; see linked known-issues page.",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Known-Issues-in-Vocabularies#snomed -Identity,Version lineage,Concept replaced by,Concept replaced by,Handles lifecycle transitions and deprecated concept history.,, -Identity,Version lineage,Concept replaces,Concept replaces,Handles lifecycle transitions and deprecated concept history.,, -Attribute,Procedural,Has dir subst,Has direct substance (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Administration of SARS-CoV-2 mRNA vaccine] -> [SARS-CoV-2 mRNA vaccine],Direct substance (attribute) describes the Substance or Pharmaceutical/biologic product on which the procedure's method directly acts.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#direct-substance -Attribute,Clinical,Has non-avail ind,Has VMP non-availability indicator (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators.",, -Association,Therapeutic,AB-drug cjgt of,Antibody-drug conjugate of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Loncastuximab tesirine] -> [Loncastuximab tesirine monotherapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Anatomical,Absorbability of,Absorbability of (SNOMED),"Spatial qualifier describing location, morphology, or extent. Example: [Bioabsorbable] -> [Surgical plant polysaccharide hemostatic agent]",Represents the absorbability aspect of an object.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/physical-object/physical-object-defining-attributes#has-absorbability -Attribute,Procedural,Acc device used by,Access device used by (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Echoendoscope] -> [Endoscopic ultrasonography guided core needle biopsy of oesophagus]",, -Association,Therapeutic,Accepted use of,Accepted use of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Esophageal squamous cell carcinoma] -> [Fluorouracil],, -Attribute,Procedural,Access of,Access of (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Percutaneous approach - access] -> [Closed reduction and percutaneous fixation of bilateral sacral ala fracture using screw]","Access (attribute) describes the route used to access the site of a procedure. It distinguishes open, closed, and percutaneous procedures.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index -Association,Administrative,Add monitor ind of,NHS dm+d (dictionary of medicines and devices) additional monitoring indicator of,"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Attribute,Procedural,Admin method of,Dose form administration method of (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Insufflate] -> [Gas for insufflation]",, -Attribute,Procedural,Affected by process,Affected by process (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Whey protein] -> [Measured quantity of intake of whey protein in 24 hours]","Describes that a process specifically acts on some entity, e.g. by transporting that entity in or out of the body, i.e. Rate of intake of protein.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/observable-entity/observable-entity-defining-attributes#process-acts-on -Attribute,Pharmaceutical,AMP restr ind of,NHS dm+d (dictionary of medicines and devices) AMP (actual medicinal product) availability restriction indicator of,Qualifies physical drug delivery properties or dose forms.,, -Composition,Logical,Answer of,Answer of,"Conceptual grouping/aggregation of entities (e.g., Q&A, HOI, SMQ, Schema). Example: [Skin@None; no surgery of primary site; autopsy ONLY] -> [RX Hosp--Surg Prim Site]",, -Association,Therapeutic,Anticoag Rx of,Anticoag Rx of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [enoxaparin] -> [Enoxaparin monotherapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Pharmaceutical,ARP of,ARP of (SNOMED),Qualifies physical drug delivery properties or dose forms.,, -Association,Diagnostic,Asso finding of,Associated finding of (SNOMED),Links diagnostic evidence or tests to a clinical concept/state. Example: [Sickle cell-hemoglobin SS disease] -> [NHS Sickle Cell and Thalassaemia Screening Programme result consistent with Hb SS (sickle cell-Haemoglobin SS) disease],, -Attribute,Anatomical,Asso morph of,Associated morphology of (SNOMED),Defines a physical/structural abnormality at the cellular or tissue level. Example: [Diffuse intrinsic pontine glioma] -> [Primary diffuse intrinsic pontine glioma],specifies the morphologic changes seen at the tissue or cellular level that are characteristic of a disease.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#associated-morphology -Association,Diagnostic,Asso proc of,Associated procedure of (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Hospital admission] -> [H/O: Admission in last year for diabetes foot problem]",, -Association,Administrative,Asso visit of,Associated visit of (OMOP),"Operational link for system billing, metadata, dictionaries, or encounter tracking. Example: [Telehealth Provided Other than in Patient’s Home] -> [Online assessment and management service provided by a qualified nonphysician health care professional to an established patient or guardian, not originating from a related assessment and management service provided within the previous 7 d... (Deprecated)]",, -Association,Diagnostic,Asso with finding,Associated with finding (SNOMED),Links diagnostic evidence or tests to a clinical concept/state. Example: [Hypoglycemia] -> [Neuropathy associated with hypoglycemia],, -Association,Mapping,ATC - RxNorm,ATC to RxNorm,"This connects an ATC class to a drug product, not an ingredient, so it is often coarser than its near-equivalence label suggests. A multi-ingredient combination product can map to the ATC class of only one of its ingredients, which is not a faithful equivalence. Example: [moxidectin; systemic] -> [moxidectin Oral Gel]","Connects ATC Classes and Drug Products, but not Ingredients",https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-ATC#other-information -Identity,Exact equivalence,ATC - RxNorm name,ATC to RxNorm equivalent by concept_name (OMOP),"Explicitly tagged as equivalent, representing exact semantic identity.",, -Identity,Exact equivalence,ATC to NDFRT eq,ATC to NDFRT equivalent (NDF-RT),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [Blood coagulation factors] -> [Blood Coagulation Factors]",Is a cross-link between ATC Classes and National Drug File - Reference Terminology.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-ATC#other-information -Identity,Exact equivalence,ATC to VA Class eq,ATC to VA Class equivalent (NDF-RT),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [ANTACIDS] -> [ANTACIDS]",Is a cross-link between ATC and Veteran Affairs drug classification.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-ATC#other-information -Composition,Structural,Basis str subst of,Basis of strength substance of (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Berotralstat] -> [Berotralstat (as berotralstat hydrochloride) 110 mg oral capsule],, -Identity,Near equivalence,Biosimilar of,Biosimilar of,"Links commercial or contextually similar entities (e.g., brand names, biosimilars, external maps). Example: [Pegfilgrastim-bmez] -> [Pegfilgrastim]",, -Hierarchy,Categorical – up,CAP parent item of,CAP parent item of,Assigns a concept to a high-level grouping or classification chapter.,, -Hierarchy,Categorical – up,CAP protocol of,CAP protocol of,Assigns a concept to a high-level grouping or classification chapter.,, -Hierarchy,Categorical – up,CAP-Nebraska cat,CAP to Nebraska Lexicon category,Assigns a concept to a high-level grouping or classification chapter.,, -Identity,Exact equivalence,CAP-Nebraska eq,CAP to Nebraska Lexicon equivalent,"Explicitly tagged as equivalent, representing exact semantic identity.",, -Hierarchy,Categorical – up,Category in Chapter,Category in Chapter,Assigns a concept to a high-level grouping or classification chapter.,, -Association,Causal,Causative agent of,Causative agent of (SNOMED),"Relates conditions to their biological, pathological, or environmental causes. Example: [SARS-CoV-2 antigen vaccine] -> [Adverse reaction to COVID-19 antigen vaccine]","identifies an organism, substance, physical object, physical force, and/or pharmaceutical/biological product as the direct cause of a condition",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#causative-agent -Association,Administrative,CD category of,Controlled drug cathehory of (DM+D),"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Hierarchy,Categorical – up,Chapter has Category,Chapter has Category,Assigns a concept to a high-level grouping or classification chapter.,, -Hierarchy,Categorical – up,Chapter to ICDO,Chapter to ICDO,Assigns a concept to a high-level grouping or classification chapter.,, -Attribute,Anatomical,Characterizes,Characterizes (SNOMED),"Spatial qualifier describing location, morphology, or extent. Example: [Measured quantity of intake of potassium in 24 hours] -> [Introduction procedure]","Specifies the process which the property describes, and on which the property (of this observable) depends",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/observable-entity/observable-entity-defining-attributes#characterizes -Attribute,Anatomical,Chem structure of,Chemical structure of (NDF-RT),"Spatial qualifier describing location, morphology, or extent. Example: [tripeptidyl-peptidase 1] -> [CERLIPONASE ALFA]",, -Identity,Exact equivalence,Chem to Prep eq,Chemical Structure to Pharmaceutical Preparation equivalent (NDF-RT),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [Mastic Resin] -> [GUM MASTIC]",, -Association,Therapeutic,CI by,Contraindicated by (NDF-RT),"Links therapeutic interventions to clinical indications or contraindications. Example: [HYDROGEN PEROXIDE 3% SPRAY,TOP] -> [Drug Hypersensitivity]",, -Association,Mechanistic,CI physiol effect by,Contraindicating physiological effect induced by (NDF-RT),"Links substances to functional biological pathways or mechanisms. Example: [Bronchoconstriction] -> [ADENOSINE 3MG/ML INJ,SYRINGE 2ML]",, -Association,Therapeutic,CI to,Contraindication to (NDF-RT),"Links therapeutic interventions to clinical indications or contraindications. Example: [Alkalosis] -> [SODIUM CHLORIDE 0.45% SOLN,IRRG]",, -Association,Administrative,CMS Specialty - NUCC,CMS Specialty to NUCC (CMS),"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Association,Administrative,Combi prod ind of,Combination product indicator of (DM+D),"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Composition,Structural,Conc denom unit of,Concentration strength denominator unit of (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Actuation] -> [Product containing precisely lidocaine 10 milligram/1 actuation conventional release oromucosal spray],, -Composition,Structural,Conc denom val of,Concentration strength denominator value of (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [One] -> [Product containing precisely sodium chloride 90 milligram/10 milliliter conventional release solution for injection vial],, -Composition,Structural,Conc num unit of,Concentration strength numerator unit of (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [millimole] -> [Product containing precisely gadodiamide 10 millimole/20 milliliter conventional release solution for injection vial],, -Composition,Structural,Conc num val of,Concentration strength numerator value of (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Eleven point four] -> [Product containing precisely povidone iodine 11.4 milligram/1 gram conventional release powder for cutaneous spray],, -Hierarchy,Taxonomic – up,Concept was_a from,Active was_a inactive (SNOMED),"SNOMED's was_a is a historical IS-A snapshot. This concept used to be classified under that parent before a model change. Not a current equivalence assertion.",, -Hierarchy,Taxonomic – up,Concept was_a to,Inactive was_a active (SNOMED),"SNOMED's was_a is a historical IS-A snapshot. This concept used to be classified under that parent before a model change. Not a current equivalence assertion.",, -Composition,Physical,Consists of,Consists of (RxNorm),Physical material assembly or structural part-whole relationship. Example: [hepatitis B immune globulin 200 UNT/ML Injectable Solution] -> [hepatitis B immune globulin 200 UNT/ML],Links a drug to its components (e.g. multi-component drugs).,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Composition,Physical,Constitutes,Constitutes (RxNorm),Physical material assembly or structural part-whole relationship. Example: [hepatitis B immune globulin 217 UNT/ML] -> [hepatitis B immune globulin 217 UNT/ML Injectable Solution],Links a component back to the whole drug.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Composition,Physical,Contained in panel,Contained in panel,Physical material assembly or structural part-whole relationship. Example: [SARS-CoV-2 (COVID-19) Ab [Presence] in Serum or Plasma by Immunoassay] -> [SARS-CoV-2 (COVID-19) Ab panel - Serum or Plasma by Immunoassay],, -Composition,Physical,Contained in version,Table/field contained in OMOP CDM Version (OMOP),Physical material assembly or structural part-whole relationship. Example: [cdm_source.cdm_holder] -> [OMOP CDM Version 5.4.0],, -Composition,Structural,Count of act ing in,Is count of active ingredients in (SNOMED),Defines the inherent chemical constituents or dosage strength.,, -Composition,Structural,Count of ing of,Count of base of active ingredient of (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Four] -> [Product containing precisely aspirin 325 milligram and butalbital 50 milligram and caffeine 40 milligram and codeine phosphate 30 milligram/1 each conventional release oral capsule],, -Hierarchy,Categorical – up,CPT4 - SNOMED cat,CPT-4 to SNOMED category (OMOP),"Assigns a concept to a high-level grouping or classification chapter. Example: [Pericardiocentesis, including imaging guidance, when performed] -> [Operation on pericardium]",, -Association,Therapeutic,Curr adult indic for,Current adult indication for (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Esophageal squamous cell carcinoma] -> [Cisplatin and Paclitaxel (TP)],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Curr ped indic for,Current pediatric indication for (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Anaplastic large cell lymphoma pediatric] -> [APO],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Current treated by,Currently treated by (HemOnc),Links therapeutic interventions to clinical indications or contraindications.,, -Identity,Near equivalence,CVX - RxNorm,CVX - RxNorm (OMOP),"Links commercial or contextually similar entities (e.g., brand names, biosimilars, external maps). Example: [Anthrax vaccine, adsorbed, adjuvanted, post-exposure prophylaxis] -> [Bacillus anthracis strain V770-NP1-R antigens 0.1 MG/ML Injectable Suspension [Cyfendus]]",, -Association,Therapeutic,Cytotox chemo RX of,Cytotoxic chemotherapy of - RxNorm (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [valproate] -> [Decitabine and Valproate],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Cytotoxic chemo of,Cytotoxic chemotherapy of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Azacitidine oral] -> [Azacitidine oral monotherapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Temporal,Date of variable,Date of variable (NAACCR),"Qualifies clinical events by their timing, sequence, or duration.",, -Composition,Structural,Denom value of,Presentation strength denominator value of (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Four] -> [Product containing precisely dalteparin sodium 100000 unit/4 milliliter conventional release solution for injection vial],, -Composition,Structural,Denominator unit of,Presentation strength denominator unit of (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Actuation] -> [Product containing precisely fluticasone propionate 500 microgram and salmeterol (as salmeterol xinafoate) 50 microgram/actuation conventional release powder for inhalation],, -Attribute,Procedural,Device used by,Device used by (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Oocyte aspiration needle] -> [Laparoscopic oocyte recovery]",, -Attribute,Procedural,Dir device of,Direct device of (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Accelerometer] -> [Provision of accelerometer]",Direct device (attribute) represents the device on which the method directly acts.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#direct-device -Attribute,Procedural,Dir subst of,Direct substance of (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [SARS-CoV-2 antigen vaccine] -> [Administration of SARS-CoV-2 antigen vaccine],Direct substance (attribute) describes the Substance or Pharmaceutical/biologic product on which the procedure's method directly acts.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#direct-substance -Attribute,Anatomical,Direction of,Direction of,"Spatial qualifier describing location, morphology, or extent. Example: [Negative] -> [Have you had any unintentional release (leakage) of urine?]",, -Attribute,Clinical,Disc indicator of,Discontinued indicator of (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators.",, -Attribute,Pharmaceutical,Disp dose form of,Dispensed dose form of (SNOMED),Qualifies physical drug delivery properties or dose forms. Example: [Tablet] -> [Levothyroxine sodium 25 microgram oral tablet],,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/pharmaceutical-and-biologic-product/index/index/pharmaceutical-dose-form-naming-and-modeling-conventions -Attribute,Pharmaceutical,Disposition of,Disposition of (SNOMED),Qualifies physical drug delivery properties or dose forms. Example: [B-cell lymphoma 2 inhibitor] -> [Venetoclax],, -Composition,Logical,DOI - RxNorm,DOI contains RxNorm (OMOP),"Conceptual grouping/aggregation of entities (e.g., Q&A, HOI, SMQ, Schema). Example: [OMOP ACE Inhibitor] -> [ramipril 2.5 MG Oral Capsule]",, -Hierarchy,Taxonomic – down,Domain subsumes,Domain subsumes (OMOP),Refinement from a broad parent to a specific child.,, -Attribute,Pharmaceutical,Dose form group of,Dose form group of (RxNorm),Qualifies physical drug delivery properties or dose forms. Example: [Pyelocalyceal Product] -> [Jelmyto Pyelocalyceal Product],, -Attribute,Pharmaceutical,Dose form unit of,Unit relating to the size of (DM+D),Qualifies physical drug delivery properties or dose forms. Example: [Tablet - unit of product usage] -> [Levothyroxine sodium 25 microgram oral tablet],, -Association,Administrative,DRG - MDC cat,DRG to MDC category (CMS),"Operational link for system billing, metadata, dictionaries, or encounter tracking. Example: [Heart transplant or implant of heart assist system w MCC] -> [Ungroupable]",, -Association,Causal,Due to of,Due to of (SNOMED),"Relates conditions to their biological, pathological, or environmental causes. Example: [Sickle cell-hemoglobin SS disease] -> [Proliferative retinopathy due to sickle cell disease]","to identify a clinical finding/disorder, event, or procedure concept as the direct cause of another Clinical finding or Disorder concept",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#due-to -Attribute,Temporal,During,During (SNOMED),"Qualifies clinical events by their timing, sequence, or duration. Example: [Shoulder girdle dystocia] -> [Delivery procedure]","Models concepts in which a clinical finding occurs during a procedure. Neither asserting nor excluding a causal relationship, it instead emphasizes a sequence of events.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#during -Association,Therapeutic,EMA indication of,EMA indication of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Esophageal squamous cell carcinoma] -> [Nivolumab],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Temporal,End date of,End date of,"Qualifies clinical events by their timing, sequence, or duration. Example: [RX Date Rad Ended] -> [Phase III Dose per Fraction]",, -Association,Therapeutic,Endocrine tx of,Endocrine therapy of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Antiestrogens] -> [Abemaciclib and ET],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Procedural,Energy used by,Energy used by (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Shockwave] -> [Fluoroscopy guided intravascular shockwave balloon lithotripsy of calcified atherosclerotic plaque of left external iliac artery with contrast]",Using energy (attribute) refers to the energy used to execute an action.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#using-energy -Attribute,Clinical,Episodicity of,Episodicity of (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [First episode] -> [New onset seizure]","represent episodes of care provided by a physician or other healthcare provider, not episodes of disease experienced by the patient",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#episodicity -Association,Mapping,ETC - RxNorm,ETC to RxNorm (FDB),"ETC is a drug classification scheme, structurally the same as ATC. This connects a drug class to a product, not an ingredient, the same coarse class-to-instance shape as ATC-RxNorm.",, -Identity,Exact equivalence,ETC - RxNorm name,ETC to RxNorm equivalent by concept_name (OMOP),"Explicitly tagged as equivalent, representing exact semantic identity.",, -Composition,Structural,Excipient of,Excipient of (SNOMED),Defines the inherent chemical constituents or dosage strength.,, -Association,Administrative,Excluded in map from,Standard to Non-standard exclude child (OMOP),"OMOP-internal mapping-exception bookkeeping (scopes/excludes a child concept from a broader Maps to relationship). Describes an exception to a mapping, not an equivalence between the concepts themselves.",, -Attribute,Anatomical,Extent of,Extent of (SNOMED),"Spatial qualifier describing location, morphology, or extent.",, -Association,Therapeutic,FDA indication of,FDA indication of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Esophageal squamous cell carcinoma] -> [Pembrolizumab],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Administrative,FDA labeling of,FDA labeling of (HemOnc),"Operational link for system billing, metadata, dictionaries, or encounter tracking. Example: [Mutation-specific] -> [Tovorafenib]",, -Association,Therapeutic,FDB Ind - ICD9CM,FDB Indication/Contraindication to ICD-9-CM (FDB),Links therapeutic interventions to clinical indications or contraindications.,, -Composition,Structural,Filling of,Filling material of (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Silicone gel] -> [Silicone gel-filled polyurethane foam covered surface breast implant],Represents the material or substance that fills an object.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/physical-object/physical-object-defining-attributes#has-filling -Association,Diagnostic,Finding asso with,Finding associated with (SNOMED),Links diagnostic evidence or tests to a clinical concept/state. Example: [Arthropathy in Behcet's syndrome] -> [Behcet's syndrome],, -Association,Diagnostic,Finding inform of,Finding informer of (SNOMED),Links diagnostic evidence or tests to a clinical concept/state. Example: [Subject of record] -> [Complaining of erectile dysfunction],Specifies the person or other entity from which the clinical finding information was obtained. It is not about the particular individual but about the category or type of informer. It is used to differentiate patient-reported symptoms from provider-determined signs.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#finding-informer -Association,Diagnostic,Finding method of,Finding method of (SNOMED),Links diagnostic evidence or tests to a clinical concept/state. Example: [Palpation of abdomen] -> [On examination - guarding - right iliac],Specifies the means by which a clinical finding was determined. It includes findings that were determined by examination of the patient.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#finding-method -Attribute,Measurement,Flavor of,Flavor of (SNOMED),"Defines metrics, scales, units, and quantitative properties of an observation.",, -Attribute,Anatomical,Focus of,Focus of (SNOMED),"Spatial qualifier describing location, morphology, or extent. Example: [Sickle cell-hemoglobin SS disease] -> [Screening for sickle cell disease]",Has focus (attribute) specifies the Clinical finding or Procedure which is the focus of a procedure. This attribute is self-grouped.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#has-focus -Attribute,Temporal,Followed by,Followed by (SNOMED),"Qualifies clinical events by their timing, sequence, or duration. Example: [Vomiting after gastrointestinal tract surgery] -> [Operation on gastrointestinal tract]",, -Attribute,Temporal,Follows,Follows (SNOMED),"Qualifies clinical events by their timing, sequence, or duration. Example: [Sickle cell-hemoglobin SS disease] -> [Acute chest syndrome due to sickle cell-hemoglobin C disease with crisis]",, -Attribute,Pharmaceutical,Form continuity of,Dose form continuity indicator (DM+D),Qualifies physical drug delivery properties or dose forms.,, -Attribute,Pharmaceutical,Form of,Form of (RxNorm),Qualifies physical drug delivery properties or dose forms. Example: [intramuscular immunoglobulin] -> [immunoglobulin G],, -Association,Administrative,Free indicator of,NHS dm+d freenes indicator of,"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Association,Administrative,Freq Code - Typ bill,Frequency code to Typ bill Full (UB04),"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Association,Therapeutic,Growth factor of,Growth factor of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Epoetin beta] -> [Erythropoetin beta and Lenalidomide],, -Association,Therapeutic,Growth factor Rx of,Growth factor Rx of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [motixafortide] -> [Motixafortide and G-CSF],, -Association,Therapeutic,Had accepted for,Had accepted for (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [MALT lymphoma] -> [Tripotassium dicitratobismuthate],, -Association,Therapeutic,Had accepted use,Had accepted use (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Stilbamidine] -> [Multiple myeloma],, -Association,Therapeutic,Has AB-drug cjgt,Has antibody-drug conjugate (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [DA 3+8 and GO] -> [Gemtuzumab ozogamicin],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Has AB-drug cjgt Rx,Has antibody-drug conjugate - RxNorm (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [DA 3+8 and GO] -> [gemtuzumab ozogamicin],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Anatomical,Has absorbability,Has absorbability (SNOMED),"Spatial qualifier describing location, morphology, or extent. Example: [Custom-made bioabsorbable polymeric pancreatic stent] -> [Bioabsorbable]",Represents the absorbability aspect of an object.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/physical-object/physical-object-defining-attributes#has-absorbability -Association,Therapeutic,Has accepted use,Has accepted use (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [AC-D] -> [ERBB2 Breast cancer],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Procedural,Has access,Has access (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Percutaneous scraping of tendon sheath] -> [Percutaneous approach]","Access (attribute) describes the route used to access the site of a procedure. It distinguishes open, closed, and percutaneous procedures.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index -Association,Administrative,Has add monitor ind,Has NHS dm+d (dictionary of medicines and devices) additional monitoring indicator,"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Attribute,Procedural,Has admin method,Has dose form administration method (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Gas for insufflation] -> [Insufflate]",, -Attribute,Pharmaceutical,Has AMP,Has Actual Medicinal Product (SNOMED),Qualifies physical drug delivery properties or dose forms.,, -Attribute,Pharmaceutical,Has AMP restr ind,Has NHS dm+d (dictionary of medicines and devices) AMP (actual medicinal product) availability restriction indicator,Qualifies physical drug delivery properties or dose forms.,, -Composition,Logical,Has Answer,Has Answer,"Conceptual grouping/aggregation of entities (e.g., Q&A, HOI, SMQ, Schema). Example: [SARS-CoV-2 (COVID-19) Ab [Presence] in Serum or Plasma by Immunoassay] -> [Nonreactive]",, -Composition,Logical,Has answer (PPI),Has answer (PPI),"Conceptual grouping/aggregation of entities (e.g., Q&A, HOI, SMQ, Schema). Example: [Did you receive the COVID-19 vaccination?] -> [Not sure]",, -Association,Therapeutic,Has anticoag Rx,Has anticoag Rx (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Aspirin and Warfarin] -> [warfarin],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Has anticoag tx,Has anticoag tx (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Aspirin and Warfarin] -> [Aspirin],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Has antineopl Rx,Has antineoplastic - RxNorm (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Amoxicillin and Omemprazole] -> [omeprazole],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Has antineoplastic,Has antineoplastic (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Amoxicillin and Omemprazole] -> [Amoxicillin],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Procedural,Has approach,Has Approach (SNOMED),"Defines execution technicalities like route of administration, method, or device used.",, -Attribute,Pharmaceutical,Has ARP,Has ARP (SNOMED),Qualifies physical drug delivery properties or dose forms.,, -Association,Diagnostic,Has asso finding,Has associated finding (SNOMED),Links diagnostic evidence or tests to a clinical concept/state. Example: [Elevated blood-pressure reading without diagnosis of hypertension] -> [Hypertensive disorder],, -Association,Diagnostic,Has asso proc,Has associated procedure (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Counselled by health visitor] -> [Counseling]",, -Attribute,Pharmaceutical,Has basic dose form,Has basic dose form (SNOMED),Qualifies physical drug delivery properties or dose forms. Example: [Gas for insufflation] -> [Gas],,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/pharmaceutical-and-biologic-product/index/index/pharmaceutical-dose-form-naming-and-modeling-conventions -Composition,Structural,Has basis str subst,Has basis of strength substance (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Calcium oxybate 500 mg/mL and magnesium oxybate 500 mg/mL and potassium oxybate 500 mg/mL and sodium oxybate 500 mg/mL oral solution] -> [Sodium oxybate],, -Identity,Near equivalence,Has biosimilar,Has biosimilar,"Links commercial or contextually similar entities (e.g., brand names, biosimilars, external maps). Example: [Bevacizumab] -> [Bevacizumab-aveg]",, -Identity,Near equivalence,Has brand name,Has brand name (OMOP),"Links commercial or contextually similar entities (e.g., brand names, biosimilars, external maps). Example: [Umifenovir] -> [Zhuangtong]",Connects an ingredient/product to a commercial brand name.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Hierarchy,Categorical – up,Has CAP parent item,Has CAP parent item,Assigns a concept to a high-level grouping or classification chapter.,, -Hierarchy,Categorical – up,Has CAP protocol,Has CAP protocol,Assigns a concept to a high-level grouping or classification chapter.,, -Attribute,Measurement,Has CAP value,Has CAP value,"Defines metrics, scales, units, and quantitative properties of an observation.",, -Association,Causal,Has causative agent,Has causative agent (SNOMED),"Relates conditions to their biological, pathological, or environmental causes. Example: [Sickle cell-hemoglobin SS disease] -> [Hemoglobin S]","identifies an organism, substance, physical object, physical force, and/or pharmaceutical/biological product as the direct cause of a condition",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#causative-agent -Association,Administrative,Has CD category,Has controlled drug category (DM+D),"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Attribute,Anatomical,Has chem structure,Has chemical structure (NDF-RT),"Spatial qualifier describing location, morphology, or extent. Example: [CLOTRIMAZOLE 1% SPRAY,TOP] -> [Azoles]",, -Association,Therapeutic,Has CI,Has drug contra-indication (FDB),Links therapeutic interventions to clinical indications or contraindications.,, -Hierarchy,Categorical – down,Has CI chem class,Has contraindicating chemical class (NDF-RT),"Expands a classification group into its constituent members. Example: [SOTALOL 15MG/ML INJ,SOLN,10ML] -> [Cisapride]",, -Association,Therapeutic,Has CI physio effect,Has contraindicating physiological effect (NDF-RT),"Links therapeutic interventions to clinical indications or contraindications. Example: [HYDROMORPHONE 0.2MG/ML/NACL 0.9% INJ,BAG,100ML] -> [Decreased Central Nervous System Organized Electrical Activity]",, -Attribute,Clinical,Has clinical course,Has clinical course (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Chronic peptic ulcer with hemorrhage AND with perforation but without obstruction] -> [Chronic]",used to represent both the course and onset of a disease or condition,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#clinical-course -Composition,Structural,Has coating material,Has coating material (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Foley 2-way polytetrafluoroethylene coated latex female length urethral catheter with water filled syringe] -> [Polytetrafluoroethylene],Represents the material with which an object is coated,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/physical-object/physical-object-defining-attributes#has-coating-material -Association,Administrative,Has combi prod ind,Has combination product indicator (DM+D),"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Composition,Physical,Has comp material,Has compositional material (SNOMED),Physical material assembly or structural part-whole relationship. Example: [Transcatheter biologic tricuspid valve prosthesis] -> [Tissue graft - material],Represents the site where the device is intended to reside in or on the body.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/physical-object/physical-object-defining-attributes#has-compositional-material -Association,Diagnostic,Has complication,Has complication (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Procedure] -> [Accidental puncture during a procedure on a blood vessel]","Is an unexpected condition, outcome, or adverse event due to another condition, procedure, or treatment.",https://docs.snomed.org/snomed-ct-specifications/snomed-ct-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-and-disorder-modeling/complication-and-sequela-modeling#complication -Composition,Structural,Has conc denom val,Has concentration strength denominator value (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Polyvinyl alcohol 1.4% conventional release eye drops] -> [One],, -Composition,Structural,Has conc num val,Has concentration strength numerator value (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Polyvinyl alcohol 1.4% conventional release eye drops] -> [Fourteen],, -Composition,Structural,Has count of act ing,Count of active ingredients (SNOMED),Defines the inherent chemical constituents or dosage strength.,, -Composition,Structural,Has denomin value,Has presentation strength denominator value (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [AbobotulinumtoxinA 500unit lyophilized powder for conventional release solution for injection] -> [One],, -Composition,Structural,Has denominator unit,Has presentation strength denominator unit (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Elagolix sodium 300 mg oral capsule] -> [Capsule],, -Attribute,Anatomical,Has Direction,Has Direction,"Spatial qualifier describing location, morphology, or extent. Example: [Additional help outside the hospital (e.g. help with daily activities, self help groups, district nurses)?] -> [Positive]",, -Attribute,Pharmaceutical,Has disp dose form,Has dispensed dose form (SNOMED),Qualifies physical drug delivery properties or dose forms. Example: [Levothyroxine sodium 50 microgram oral tablet] -> [Tablet],,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/pharmaceutical-and-biologic-product/index/index/pharmaceutical-dose-form-naming-and-modeling-conventions -Attribute,Pharmaceutical,Has dose form unit,Has unit relating to the size (DM+D),Qualifies physical drug delivery properties or dose forms. Example: [Levothyroxine sodium 50 microgram oral tablet] -> [Tablet - unit of product usage],, -Attribute,Temporal,Has end date,Has end date,"Qualifies clinical events by their timing, sequence, or duration. Example: [Phase III Radiation Treatment Modality] -> [RX Date Rad Ended]",, -Association,Therapeutic,Has endocrine tx,Has endocrine therapy (HemOnc),"Links therapeutic interventions to clinical indications or contraindications. Example: [Anastrozole, Lapatinib, Trastuzumab] -> [Anastrozole]",,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Has FDA indication,Has FDA indication (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Brexucabtagene autoleucel] -> [B-cell acute lymphoblastic leukemia],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Composition,Structural,Has filling,Has filling (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Saline-filled smooth breast implant] -> [Sodium chloride],Represents the material or substance that fills an object.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/physical-object/physical-object-defining-attributes#has-filling -Attribute,Pharmaceutical,Has form,Has form (RxNorm),"Qualifies physical drug delivery properties or dose forms. Example: [hepatitis B surface antigen vaccine] -> [hepatitis B surface antigen (isoform S), recombinant]",, -Attribute,Pharmaceutical,Has form continuity,Has dose form continuity indicator (DM+D),Qualifies physical drug delivery properties or dose forms.,, -Attribute,Anatomical,Has Histology ICDO,Has Histology ICDO,"Spatial qualifier describing location, morphology, or extent. Example: [Carcinoma, metastatic, NOS, of lymph nodes of inguinal region or leg] -> [Carcinoma, metastatic, NOS]",, -Attribute,Procedural,Has indir device,Has indirect device (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Implantation of automatic cardioverter/defibrillator pulse generator only] -> [Implantable defibrillator]","Indirect device (attribute) represents action on something that is located in or on a device, but is not directly on the device itself. This attribute is infrequently needed. When modeling, carefully consider its use.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#indirect-device -Attribute,Anatomical,Has inherent loc,Has inherent location (SNOMED),"Spatial qualifier describing location, morphology, or extent. Example: [Length of primary malignant neoplasm of prostate in resected prostate specimen] -> [Prostatic structure]",Specify a body site or other location of the independent continuant in which the property exists.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/observable-entity/observable-entity-defining-attributes#inherent-location -Association,Diagnostic,Has interpretation,Has interpretation (SNOMED),Links diagnostic evidence or tests to a clinical concept/state. Example: [Sickle cell-hemoglobin SS disease] -> [Below reference range],"Refers to and designates the judgment aspect being evaluated or interpreted (e.g., presence, absence, degree, normality, abnormality, etc.). ",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#has-interpretation -Association,Therapeutic,Has investig use,Has investigational use (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Dalpiciclib] -> [Breast cancer],, -Hierarchy,Categorical – down,Has kind,Has kind (LOINC),Expands a classification group into its constituent members. Example: [COVID-19 Intubation Consultation note] -> [Note],,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Hierarchy,Categorical – up,Has legal category,Has legal category (SNOMED),Assigns a concept to a high-level grouping or classification chapter.,, -Attribute,Clinical,Has life circumstan,Has life circumstance (SNOMED Vet),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Asian sea bass] -> [Lives in water]",, -Association,Therapeutic,Has local therapy,Has local therapy (HemOnc),"Links therapeutic interventions to clinical indications or contraindications. Example: [Doxorubicin, Pegaspargase, Vincristine, Prednisone] -> [Hydrocortisone]",,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Diagnostic,Has manifestation,Has definitional manifestation (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Bilateral pseudophakia] -> [Finding of artificial lens]",, -Attribute,Pharmaceutical,Has marketed form,Has marketed form (OMOP),Qualifies physical drug delivery properties or dose forms. Example: [hepatitis B immune globulin 200 UNT/ML Injectable Solution] -> [10 ML hepatitis B immune globulin 200 UNT/ML Injectable Solution [HEPABIG] by Greencross],Identifies the version of the drug that is marketed.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Association,Mechanistic,Has metabolism,Has site of metabolism (NDF-RT),Links substances to functional biological pathways or mechanisms. Example: [METOPROLOL TARTRATE 37.5MG TAB] -> [Hepatic Metabolism],, -Association,Mechanistic,Has metabolites,Has active metabolites (NDF-RT),Links substances to functional biological pathways or mechanisms. Example: [AMITRIPTYLINE HYDROCHLORIDE] -> [Nortriptyline],, -Attribute,Procedural,Has method,Has method,"Defines execution technicalities like route of administration, method, or device used. Example: [Tc-99 MIBI (technetium 99m methoxyisobutylisonitrile) parathyroid subtraction study] -> [Radionuclide imaging - action]","Defines the action or technique used (e.g., Excision).",https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-SNOMED#attribute-relationships -Attribute,Procedural,Has modality,Has modality (HemOnc),"Defines execution technicalities like route of administration, method, or device used. Example: [AC-D] -> [Chemotherapy]",, -Composition,Structural,Has numerator unit,Has presentation strength numerator unit (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Elagolix sodium 300 mg oral capsule] -> [mg],, -Composition,Structural,Has numerator value,Has presentation strength numerator value (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [AbobotulinumtoxinA 500unit lyophilized powder for conventional release solution for injection] -> [Five hundred],, -Attribute,Temporal,Has occurrence,Has occurrence (SNOMED),Links therapeutic interventions to clinical indications or contraindications. Example: [Sickle cell-hemoglobin SS disease] -> [Congenital],"Occurrence (attribute) is applied to procedures during a period of life. For example, procedures related to the gravid uterus should be modeled by the combination of the occurrence of the maternal pregnancy period and the uterus.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#occurrence -Association,Therapeutic,Has off-label ind,Has off-label drug indication (FDB),Links therapeutic interventions to clinical indications or contraindications.,, -Attribute,Pharmaceutical,Has ontological form,Has ontological dose form presentation (DM+D),Qualifies physical drug delivery properties or dose forms.,, -Composition,Containment,Has pack,Has pack (SNOMED),Links items to their physical packaging or multi-item kit groupings.,, -Hierarchy,Categorical – down,Has parent item,Has parent item (NAACCR),Expands a classification group into its constituent members. Example: [Regional Nodes Positive] -> [Primary Site],, -Composition,Physical,Has part of,Has part of (SNOMED),Physical material assembly or structural part-whole relationship. Example: [Promyelomonocyte] -> [Structure of myelopoietic tissue],, -Association,Therapeutic,Has PDC Rx,Has PDC - RxNorm (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Melphalan flufenamide and Dexamethasone] -> [melphalan flufenamide],, -Attribute,Measurement,Has permiss range,Has permissible range,"Defines metrics, scales, units, and quantitative properties of an observation. Example: [Regional Nodes Positive] -> [1 - 89 nodes positive (code exact number of nodes positive)]",, -Hierarchy,Categorical – up,Has PPI parent code,Has PPI parent code,Assigns a concept to a high-level grouping or classification chapter. Example: [1] -> [How many doses of the vaccine did you receive?],, -Composition,Logical,Has precoord pair,Has precoordinated (Question-Answer/Variable-Value) pair,"Conceptual grouping/aggregation of entities (e.g., Q&A, HOI, SMQ, Schema). Example: [EuroQol five dimension three level self-care score] -> [EuroQol five dimension three level self-care score: 2 (I have some problems washing or dressing myself)]",, -Attribute,Clinical,Has priority,Has priority (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Emergency operation following delivery] -> [Emergency]",Priority (attribute) is used when a procedure concept specifies a priority. This attribute is self-grouped.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#priority -Composition,Physical,Has product comp,Has product component (NDF-RT),"Physical material assembly or structural part-whole relationship. Example: [HYDROGEN PEROXIDE 3% SPRAY,TOP] -> [HYDROGEN PEROXIDE]",, -Composition,Structural,Has radioconjugate,Has radioconjugate (HemOnc),"Defines the inherent chemical constituents or dosage strength. Example: [Busulfan, Fludarabine, Ibritumomab tiuxetan] -> [Ibritumomab tiuxetan]",,https://hemonc.org/wiki/HemOncKB_canonical_triples -Hierarchy,Categorical – up,Has recipient cat,Has recipient category (SNOMED),Assigns a concept to a high-level grouping or classification chapter. Example: [Removal of organ from cadaver] -> [Cadaver donor],Recipient category (attribute) specifies the type of individual or group upon which the action of the procedure is performed.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#recipient-category -Association,Therapeutic,Has regimen type,Has regimen type (HemOnc),Links therapeutic interventions to clinical indications or contraindications.,, -Attribute,Measurement,Has scale type,Has scale type,"Defines metrics, scales, units, and quantitative properties of an observation. Example: [SARS-CoV-2 (COVID-19) Ab [Presence] in Serum or Plasma by Immunoassay] -> [Ord]",, -Association,Diagnostic,Has specimen proc,Has specimen procedure (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Swab from bite wound] -> [Taking of swab]",Identifies the procedure by which a specimen is obtained.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/specimen/specimen-defining-attributes#specimen-procedure -Attribute,Anatomical,Has specimen topo,Has specimen source topography (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Cervicovaginal washout fluid sample] -> [Vaginal cervix]",, -Attribute,Pharmaceutical,Has standard form,Has standard Dose Form (OMOP),Qualifies physical drug delivery properties or dose forms.,, -Attribute,Temporal,Has start date,Has start date,"Qualifies clinical events by their timing, sequence, or duration. Example: [Regional Nodes Positive] -> [Date of Diagnosis]",, -Association,Therapeutic,Has steroid tx,Has steroid therapy (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Dara-KRd] -> [Dexamethasone],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Has steroid tx Rx,Has steroid therapy - RxNorm (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Dara-KRd] -> [dexamethasone],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Diagnostic,Has sub-specimen,Includes sub-specimen (SNOMED Vet),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Pooled cloacal/oropharyngeal swab] -> [Cloacal swab]",, -Hierarchy,Categorical – up,Has subject matter,Has subject matter domain (LOINC),Assigns a concept to a high-level grouping or classification chapter. Example: [Critical care medicine COVID-19 Consultation note] -> [Critical care medicine],,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Association,Administrative,Has supplier,Has supplier or manufacturer (OMOP),"Operational link for system billing, metadata, dictionaries, or encounter tracking. Example: [pyridoxine 100 MG/ML Injection by Hospira] -> [Hospira]",, -Association,Therapeutic,Has support med Rx,Has supportive medication - RxNorm (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [AVML] -> [leucovorin],, -Association,Therapeutic,Has supportive med,Has supportive medication (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [AVML] -> [Leucovorin],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Anatomical,Has surface char,Has surface characteristic (SNOMED),"Spatial qualifier describing location, morphology, or extent. Example: [Textured] -> [Silicone gel-filled textured breast implant]",, -Association,Therapeutic,Has synth regimen,Has synthetic regimen (HemOnc),"Links therapeutic interventions to clinical indications or contraindications. Example: [Anastrozole, Lapatinib, Trastuzumab] -> [Anastrozole, Lapatinib, Trastuzumab-pkrb]",, -Attribute,Anatomical,Has system,Has system,"Spatial qualifier describing location, morphology, or extent. Example: [SARS-CoV-2 (COVID-19) Ab [Presence] in Serum or Plasma by Immunoassay] -> [Serum or Plasma]",, -Attribute,Clinical,Has targ population,Has target population (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Acellular Bordetella pertussis, Clostridium tetani toxoid, Corynebacterium diphtheriae toxoid, Haemophilus influenzae type b conjugated, Hepatitis B virus and inactivated Human poliovirus antigens only pediatric vaccine product] -> [Pediatric population]",, -Association,Therapeutic,Has targeted therapy,Has targeted therapy (HemOnc),"Links therapeutic interventions to clinical indications or contraindications. Example: [Anastrozole, Lapatinib, Trastuzumab] -> [Trastuzumab]",,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Procedural,Has technique,Has technique (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Personal Wellbeing Score rating] -> [Personal Wellbeing Score rating scale]",Specify the systematic method of an observation., -Hierarchy,Categorical – down,Has type,Has type,Expands a classification group into its constituent members. Example: [Regional Nodes Positive] -> [Numeric],, -Attribute,Measurement,Has unit of presen,Has unit of presentation (SNOMED),"Defines metrics, scales, units, and quantitative properties of an observation. Example: [Elagolix sodium 300 mg oral capsule] -> [Capsule]",, -Association,Diagnostic,Has variant,Reference to variant,"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Hemolytic disease of fetus OR newborn due to ABO immunization] -> [ABO protein expression measurement]",, -Attribute,Pharmaceutical,Has VMP,Has Virtual Medicinal Product (SNOMED),Qualifies physical drug delivery properties or dose forms.,, -Attribute,Pharmaceutical,Has VRP,Has VRP (SNOMED),Qualifies physical drug delivery properties or dose forms.,, -Association,Therapeutic,HC indication of,HC indication of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [B-cell acute lymphoblastic leukemia pediatric] -> [Blinatumomab],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Administrative,HCPCS - SNOMED proc,HCPCS is a SNOMED procedure (OMOP),"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Association,Therapeutic,Hist adult indic for,Historical adult indication for (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [MALT lymphoma] -> [Chlorambucil monotherapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Hist ped indic for,Historical pediatric indication for (HemOnc),"Links therapeutic interventions to clinical indications or contraindications. Example: [Acute myeloid leukemia pediatric] -> [Cytarabine, Doxorubicin, Vincristine, Prednisolone]",,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Anatomical,Histology of ICDO,Histology of ICDO,"Spatial qualifier describing location, morphology, or extent. Example: [Myofibroblastic sarcoma] -> [Myofibroblastic sarcoma of overlapping lesion of larynx]",, -Association,Therapeutic,Historic treated by,Historically treated by (HemOnc),Links therapeutic interventions to clinical indications or contraindications.,,https://hemonc.org/wiki/HemOncKB_canonical_triples -Composition,Logical,HOI - MedDRA,HOI contains MedDRA (OMOP),"Conceptual grouping/aggregation of entities (e.g., Q&A, HOI, SMQ, Schema).",, -Composition,Logical,HOI - SNOMED,HOI contains SNOMED (OMOP),"Conceptual grouping/aggregation of entities (e.g., Q&A, HOI, SMQ, Schema). Example: [OMOP Angioedema 1] -> [Angioedema]",, -Association,Mapping,ICD9CM - MedDRA,ICD-9-CM to MedDRA (MSSO),"This bridges adverse-event terminology (MedDRA) and diagnosis codes (ICD9CM), two systems built for different purposes and at different granularity. It is a lossy cross-vocabulary mapping, not a guaranteed 1:1 correspondence.",, -Association,Mapping,ICDO - SNOMED,ICDO - SNOMED,"ICDO is a coarse topography and morphology coding system for cancer registries, while SNOMED is fine-grained clinical terminology. This is a lossy cross-vocabulary mapping, the same shape as OPCS4-SNOMED, not a guaranteed 1:1 correspondence.",, -Hierarchy,Taxonomic – up,ICDO br to OncoTree,ICDO broader to OncoTree,"Inheritance link generalizing to a broader parent. Example: [Intraductal papilloma of pancreas, NOS] -> [Intraductal Oncocytic Papillary Neoplasm (IOPN)]",, -Hierarchy,Categorical – up,ICDO to Chapter,ICDO to Chapter,Assigns a concept to a high-level grouping or classification chapter.,, -Association,Diagnostic,ICDO to Proc Schema,ICDO Condition to Procedure Schema (NAACCR),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Mixed tumor, malignant, NOS, of unknown primary site] -> [Unknown And Ill-Defined Primary Sites]",, -Hierarchy,Categorical – up,ICDO to Schema,ICDO Condition to Schema (NAACCR),"Assigns a concept to a high-level grouping or classification chapter. Example: [Mixed tumor, malignant, NOS, of unknown primary site] -> [Other and Ill-Defined Sites, Unknown Primary Site]",, -Association,Administrative,Incipient of,Incipient of (DM+D),"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Association,Administrative,Included in map from,Standard to Non-standard include child (OMOP),"OMOP-internal mapping-exception bookkeeping (scopes/includes a child concept in a broader Maps to relationship). Describes an exception to a mapping, not an equivalence between the concepts themselves.",, -Hierarchy,Categorical – up,Inferred class of,Inferred drug class of (OMOP),Assigns a concept to a high-level grouping or classification chapter.,, -Attribute,Anatomical,Inherent location of,Inherent location of (SNOMED),"Spatial qualifier describing location, morphology, or extent. Example: [Proximal surgical margin] -> [Histologic grade of dysplasia at proximal surgical margin]",Specify a body site or other location of the independent continuant in which the property exists.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/observable-entity/observable-entity-defining-attributes#inherent-location -Association,Interaction,Inhibits effect,Inhibits effect (NDF-RT),Defines safety or therapeutic interference between agents. Example: [CYPROHEPTADINE] -> [FLUOXETINE HCL 20MG CAP],, -Association,Diagnostic,Interpretation of,Interpretation of (SNOMED),Links diagnostic evidence or tests to a clinical concept/state. Example: [Negative] -> [Rubella non-immune],"Refers to and designates the judgment aspect being evaluated or interpreted (e.g., presence, absence, degree, normality, abnormality, etc.). ",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#has-interpretation -Association,Therapeutic,Is CI of,Is contra-indication of (FDB),Links therapeutic interventions to clinical indications or contraindications.,, -Association,Therapeutic,Is current in,Is currently used in (HemOnc),Links therapeutic interventions to clinical indications or contraindications.,, -Association,Therapeutic,Is FDA-appr ind of,Is FDA-approved indication of (FDB),Links therapeutic interventions to clinical indications or contraindications.,, -Association,Therapeutic,Is hist in adult,Is historical in adult (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [AC-D] -> [Breast cancer],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Is hist in ped,Is historical in pediatric (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [ABVE] -> [Classical Hodgkin lymphoma pediatric],, -Association,Therapeutic,Is historical in,Is historically used in (HemOnc),Links therapeutic interventions to clinical indications or contraindications.,, -Identity,Near equivalence,Is standard brand of,Is standard Brand Name of (OMOP),"Links commercial or contextually similar entities (e.g., brand names, biosimilars, external maps).",, -Attribute,Pharmaceutical,Is standard form of,Is standard Dose Form of (OMOP),Qualifies physical drug delivery properties or dose forms.,, -Attribute,Pharmaceutical,Is sterile,Is sterile (SNOMED),Qualifies physical drug delivery properties or dose forms. Example: [Polyurethane foam sterile film dressing with non-adhesive border] -> [True],Represents the sterility quality (e.g. sterile or non-sterile) of an object.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/physical-object/physical-object-defining-attributes#is-sterile -Attribute,Pharmaceutical,Is sterile of,Is sterile of (SNOMED),"Qualifies physical drug delivery properties or dose forms. Example: [True] -> [Tracheostomy tube cannula cleaning swab, sterile]",Represents the sterility quality (e.g. sterile or non-sterile) of an object.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/physical-object/physical-object-defining-attributes#is-sterile -Association,Mechanistic,Is transcribed from,mRNA is transcribed from genomic DNA,Links substances to functional biological pathways or mechanisms. Example: [APC transcript: Deletion in position 3317 measurement] -> [APC on GRCh38 chr5: Deletion in position 112838911 measurement],, -Attribute,Clinical,Kind of,Kind of (LOINC),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Agreement] -> [Mental health Agreement]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Hierarchy,Categorical – up,Legal category of,Legal category of (SNOMED),Assigns a concept to a high-level grouping or classification chapter.,,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Attribute,Procedural,Licensed route of,Licensed route of (DM+D),"Defines execution technicalities like route of administration, method, or device used.",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Attribute,Clinical,Life circumstan of,Life circumstance (SNOMED Vet),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Used as breeding stock] -> [Breeding queen]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Association,Therapeutic,Local therapy of,Local therapy of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Carboplatin] -> [TACE monotherapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Identity,Version lineage,LOINC replaced by,Concept replaced by (LOINC),Handles lifecycle transitions and deprecated concept history.,, -Identity,Version lineage,LOINC replaces,Concept replaces (LOINC),Handles lifecycle transitions and deprecated concept history.,, -Association,Diagnostic,Manifestation of,Definitional manifestation of (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Diarrhea] -> [Neonatal calf diarrhoea]",, -Association,Administrative,Map excludes child,Non-standard to Standard exclude child (OMOP),"OMOP-internal mapping-exception bookkeeping (scopes/excludes a child concept from a broader Maps to relationship). Describes an exception to a mapping, not an equivalence between the concepts themselves.",, -Association,Administrative,Map includes child,Non-standard to Standard include child (OMOP),"OMOP-internal mapping-exception bookkeeping (scopes/includes a child concept in a broader Maps to relationship). Describes an exception to a mapping, not an equivalence between the concepts themselves.",, -Identity,Exact equivalence,Mapped from,Standard to Non-standard map (OMOP),Direct semantic identity or mapping rule across different vocabularies. Example: [Emergency Room and Inpatient Visit] -> [Emergency Room and Inpatient Visit],, -Identity,Exact equivalence,Maps to,Non-standard to Standard map (OMOP),Direct semantic identity or mapping rule across different vocabularies. Example: [Emergency Room and Inpatient Visit] -> [Emergency Room and Inpatient Visit],, -Identity,Exact equivalence,Maps to number,Non-standard to value_as_number map (OMOP),Direct semantic identity or mapping rule across different vocabularies.,, -Identity,Exact equivalence,Maps to operator,Non-standard to operator concept map (OMOP),Direct semantic identity or mapping rule across different vocabularies.,, -Identity,Exact equivalence,Maps to type,Non-standard to type concept map (OMOP),Direct semantic identity or mapping rule across different vocabularies.,, -Identity,Exact equivalence,Maps to unit,Non-standard to unit concept map (OMOP),Direct semantic identity or mapping rule across different vocabularies. Example: [Most recent bowel cancer screening] -> [year],, -Identity,Exact equivalence,Maps to value,Non-standard to value_as_concept map (OMOP),Direct semantic identity or mapping rule across different vocabularies. Example: [Other medical devices associated with adverse incidents] -> [Clinical equipment and/or device],,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-PPI#concept-relationships -Association,Interaction,May be inhibited by,Effect may be inhibited by (NDF-RT),Defines safety or therapeutic interference between agents. Example: [FLUOXETINE] -> [CYPROHEPTADINE],, -Association,Therapeutic,May be prevented by,May be prevented by (NDF-RT),Links therapeutic interventions to clinical indications or contraindications. Example: [Invasive Fungal Infections] -> [DOMPERIDONE],, -Attribute,Procedural,May have route,May have route (HemOnc),"Defines execution technicalities like route of administration, method, or device used. Example: [Acetazolamide] -> [Oral]",, -Association,Administrative,MDC cat - DRG,MDC category to DRG (CMS),"Operational link for system billing, metadata, dictionaries, or encounter tracking. Example: [Ungroupable] -> [Heart transplant or implant of heart assist system w MCC]",, -Association,Mechanistic,Metabolism of,Site of metabolism of (NDF-RT),"Links substances to functional biological pathways or mechanisms. Example: [Hepatic Metabolism] -> [WARFARIN NA 2.5MG TAB,UD]",, -Association,Mechanistic,Metabolite of,Active metabolite of (NDF-RT),"Links substances to functional biological pathways or mechanisms. Example: [Morphine] -> [CODEINE PO4 15MG/5ML SOLN,ORAL]",, -Attribute,Procedural,Modality of,Modality of (Hemonc),"Defines execution technicalities like route of administration, method, or device used. Example: [Antibody-drug conjugate therapy] -> [Azacitidine, Vorinostat, Gemtuzumab ozogamicin]",, -Composition,Structural,Modification of,Is modification of (SNOMED),"A salt/ester/hydrate structural relationship between a substance and its modified form, not a deprecated-concept link. Example: [Trilaciclib dihydrochloride] -> [Trilaciclib]",, -Hierarchy,Categorical – up,Module of,Module of,Assigns a concept to a high-level grouping or classification chapter. Example: [US National Library of Medicine maintained module] -> [Open fracture of shaft of second metacarpal bone of left hand],, -Composition,Structural,Multilex has ing,Has ingredient (Multilex),Defines the inherent chemical constituents or dosage strength.,, -Identity,Exact equivalence,NDFRT - RxNorm name,NDF-RT to RxNorm equivalent by concept_name (OMOP),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [ANTITHROMBIN III] -> [antithrombin III]",, -Attribute,Pharmaceutical,NDFRT has dose form,Has dose form (NDF-RT),"Qualifies physical drug delivery properties or dose forms. Example: [HYDROGEN PEROXIDE 3% SPRAY,TOP] -> [Topical Spray]",, -Hierarchy,Categorical – up,Nebraska-CAP cat,Nebraska Lexicon to CAP category,Assigns a concept to a high-level grouping or classification chapter.,, -Association,Therapeutic,NMPA indication of,NMPA indication of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Non-small cell lung cancer nonsquamous] -> [Tislelizumab],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Clinical,Non-avail ind of,VMP non-availability indicator of (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators.",, -Association,Administrative,NUCC - CMS Specialty,NUCC to CMS Specialty (CMS),"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Identity,Exact equivalence,Number mapped from,Value_as_number to non-standard map (OMOP),Direct semantic identity or mapping rule across different vocabularies.,, -Composition,Structural,Numerator unit of,Presentation strength numerator unit of (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Million unit] -> [Product containing precisely lenograstim 13.4 million unit/1 vial powder for conventional release solution for injection],, -Composition,Structural,Numerator value of,Presentation strength numerator value of (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Eight thousand] -> [Product containing precisely epoetin alfa 8000 unit/0.8 milliliter conventional release solution for injection syringe],, -Attribute,Temporal,Occurrence of,Occurrence of (SNOMED),Links therapeutic interventions to clinical indications or contraindications. Example: [Childhood - period] -> [Benign occipital epilepsy of childhood - late onset variant],"Occurrence (attribute) is applied to procedures during a period of life. For example, procedures related to the gravid uterus should be modeled by the combination of the occurrence of the maternal pregnancy period and the uterus.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#occurrence -Attribute,Temporal,Occurs after,Occurs after (SNOMED),"Qualifies clinical events by their timing, sequence, or duration. Example: [Vomiting after gastrointestinal tract surgery] -> [Operation on gastrointestinal tract]",, -Association,Mapping,OPCS4 - SNOMED,OPCS4 to SNOMED equivalent or categorical (NHS),"Per its own r_name, this bundles true 1:1 equivalences together with loose categorical or broader mappings, used when no clean OPCS4-to-SNOMED equivalent exists. It is a no-clean-mapping fallback, not a trustworthy equivalence.",, -Identity,Exact equivalence,Operator mapped from,Operator concept to non-standard map (OMOP),Direct semantic identity or mapping rule across different vocabularies.,, -Association,Administrative,Paral imprt ind,Has NHS dm+d parallel import indicator,"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Association,Administrative,Paral imprt of,NHS dm+d parallel import indicator of,"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Attribute,Measurement,Permiss range of,Permissible range of,"Defines metrics, scales, units, and quantitative properties of an observation. Example: [0-120] -> [Age at Diagnosis]",, -Composition,Containment,Pharma prep in,Pharmaceutical preparation contained in (NDF),Links items to their physical packaging or multi-item kit groupings.,, -Association,Mechanistic,Physiol effect by,Physiological effect induced by (NDF-RT),"Links substances to functional biological pathways or mechanisms. Example: [Increased Diuresis at Loop of Henle] -> [FUROSEMIDE 1MG/ML/NACL 0.9%INJ,SOLN,BAG,100ML]",, -Association,Mechanistic,Physiol state of,Physiologic state of (SNOMED Vet),Links substances to functional biological pathways or mechanisms. Example: [Male] -> [Adult male dog for breeding],, -Association,Mechanistic,PK of,Pharmacokinetics of (NDF-RT),"Links substances to functional biological pathways or mechanisms. Example: [Hepatic Metabolism] -> [IBUPROFEN 50MG/1.25ML SUSP,ORAL]",, -Association,Therapeutic,PMDA indication of,PMDA indication of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Esophageal squamous cell carcinoma] -> [Pembrolizumab],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Administrative,PoS - Visit cat,Place of Service to Visit category (OMOP),"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Hierarchy,Categorical – up,PPI parent code of,PPI parent code of,Assigns a concept to a high-level grouping or classification chapter. Example: [Did you receive the COVID-19 vaccination?] -> [Which vaccine did you receive?],, -Attribute,Temporal,Precondition of,Precondition of (SNOMED),"Qualifies clinical events by their timing, sequence, or duration. Example: [Fistula of intestine] -> [Volume of drainage of intestinal fistula]","Specify body state, timing, challenges, or other situations that must be true of the entity to be observed.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/observable-entity/observable-entity-defining-attributes#precondition -Composition,Logical,Precoord pair of,Precoordinated (Question-Answer/Variable-Value) pair of,"Conceptual grouping/aggregation of entities (e.g., Q&A, HOI, SMQ, Schema). Example: [RASS scale: +4 (Combative)] -> [Richmond Agitation-Sedation Scale]",, -Attribute,Procedural,Proc device of,Procedure device of (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Echoendoscope] -> [Endoscopic ultrasonography guided fine needle aspiration]","Procedure device (attribute) is used to model devices associated with a procedure. This attribute is used to define high-level, general concepts that aggregate procedures according to the device involved.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#procedure-device -Association,Diagnostic,Proc duration of,Process duration of,"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [6 minutes] -> [6 minute walk test distance]",, -Composition,Logical,Proc Schema to ICDO,Procedure Schema to ICDO Condition (NAACCR),"This links a cancer-registry anatomic schema (a grouping construct) to one of many possible specific conditions occurring at that site, not an equivalent concept. Its own example shows this clearly: [Liver and Intrahepatic Bile Ducts] is a broad anatomic schema, while [Precursor cell lymphoblastic lymphoma, NOS, of liver] is one specific malignancy that can occur there. This fits the same conceptual-grouping family as Schema to Variable/Variable to Schema.",, -Association,Diagnostic,Process extends from,Process extends from (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Structure of lymphatic vessel and/or small blood vessel] -> [Presence of direct invasion by primary malignant neoplasm of jejunum to lymphatic vessel and/or small blood vessel]",Specify the substance or process produced by the process characterized by the observable property type.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/observable-entity/observable-entity-defining-attributes#process-extends-to -Association,Diagnostic,Process extends to,Process extends to (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Presence of direct invasion by primary malignant neoplasm of prostate to seminal vesicle] -> [Seminal vesicle structure]",Specify the substance or process produced by the process characterized by the observable property type.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/observable-entity/observable-entity-defining-attributes#process-extends-to -Attribute,Measurement,Property of,Property of,"Defines metrics, scales, units, and quantitative properties of an observation. Example: [Histologic feature (property)] -> [Architectural pattern of ductal carcinoma in situ in breast]",, -Hierarchy,Categorical – up,Property type of,Property type of,Assigns a concept to a high-level grouping or classification chapter. Example: [Number rate] -> [Minimum pulse rate],, -Attribute,Pharmaceutical,Quantified form of,Quantified form of (RxNorm),Qualifies physical drug delivery properties or dose forms. Example: [0.5 ML hepatitis B immune globulin 217 UNT/ML Prefilled Syringe [Bayhep B]] -> [hepatitis B immune globulin 217 UNT/ML Prefilled Syringe [Bayhep B]],Quantifier for a drug concept,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Composition,Structural,Radioconjugate of,Radioconjugate of (HemOnc),"Defines the inherent chemical constituents or dosage strength. Example: [Ibritumomab tiuxetan] -> [(90)YFC, then allo HSCT (rituximab-pvvr)]",,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Radiotherapy of,Radiotherapy of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Lutetium Lu 177 vipivotide tetraxetan] -> [Lutetium Lu 177 vipivotide tetraxetan monotherapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Hierarchy,Categorical – up,Recipient cat of,Recipient category of (SNOMED),Assigns a concept to a high-level grouping or classification chapter. Example: [Live donor for lung transplant] -> [Harvesting of lobe of lung from living donor],Recipient category (attribute) specifies the type of individual or group upon which the action of the procedure is performed.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#recipient-category -Composition,Structural,Reformulated in,Reformulated in (RxNorm),"Links an older drug formulation to its reformulated version. This is a substance or modified-form relationship, in the same family as Modification of.",, -Hierarchy,Categorical – up,Regimen type of,Regimen type of (HemOnc),Assigns a concept to a high-level grouping or classification chapter.,,https://hemonc.org/wiki/HemOncKB_canonical_triples -Composition,Physical,Relative part of,Relative part of (SNOMED),Physical material assembly or structural part-whole relationship. Example: [Structure of brachial artery] -> [Ankle brachial pressure index],, -Attribute,Anatomical,Relative to,Relative to (SNOMED),"Spatial qualifier describing location, morphology, or extent. Example: [Percentage of primary malignant neoplasm of prostate in resected prostate specimen] -> [Tissue specimen obtained by excision]","Specify the denominator of a relational property type, e.g. a ratio or proportion.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/observable-entity/observable-entity-defining-attributes#relative-to -Attribute,Anatomical,Relative to of,Relative to of (SNOMED),"Spatial qualifier describing location, morphology, or extent. Example: [Non-invasive papillary urothelial carcinoma, low-grade] -> [Surgical margin involved by low grade noninvasive papillary urothelial carcinoma in ureterectomy specimen]","Specify the denominator of a relational property type, e.g. a ratio or proportion.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/observable-entity/observable-entity-defining-attributes#relative-to -Attribute,Pharmaceutical,Release charact of,Dose form release characteristic of (SNOMED),Qualifies physical drug delivery properties or dose forms. Example: [Modified-release] -> [Modified-release oral capsule],, -Attribute,Context,Role of,Role of (LOINC),"Specific situational modifier or contextual role. Example: [Patient] -> [Patient Goals, preferences, and priorities for care experience]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Attribute,Context,Role played by,Role played by,Specific situational modifier or contextual role. Example: [Insecticide therapeutic role] -> [Acephate-containing product],,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Association,Therapeutic,Rx AB-drug cjgt of,RxNorm antibody-drug conjugate of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [tisotumab] -> [Tisotumab vedotin monotherapy],,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Association,Therapeutic,Rx antineopl of,RxNorm antineoplastic of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Fam-trastuzumab deruxtecan] -> [Trastuzumab deruxtecan monotherapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Rx endocrine tx of,RxNorm endocrine therapy of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [relugolix] -> [Relugolix monotherapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Rx immunosuppr of,RxNorm immunosuppressor of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [belumosudil] -> [Belumosudil monotherapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Rx immunotherapy of,RxNorm immunotherapy of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [ropeginterferon alfa-2b] -> [Ropeginterferon alfa-2b monotherapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Rx local therapy of,RxNorm local therapy of (HemOnc),"Links therapeutic interventions to clinical indications or contraindications. Example: [Cytarabine liposomal] -> [Cytarabine, Methotrexate, Rituximab]",,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Rx PDC of,RxNorm PDC of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [melphalan flufenamide] -> [Melphalan flufenamide and Dexamethasone],, -Association,Administrative,Rx pept-drg cjg of,RxNorm peptide-drug conjugate of (HemOnc),"Operational link for system billing, metadata, dictionaries, or encounter tracking. Example: [melphalan flufenamide] -> [Melphalan flufenamide and Dexamethasone]",,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Rx radiocjgt of,RxNorm radioconjugate of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [ibritumomab tiuxetan] -> [Ibritumomab tiuxetan protocol (rituximab-rixi)],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Rx steroid tx of,RxNorm steroid therapy of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [methylprednisolone] -> [HDMP-R (rituximab-rite)],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Rx support med of,RxNorm Supportive medication of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Rho(D) immune globulin] -> [RhIG monotherapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Rx targeted tx of,RxNorm targeted therapy of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [belzutifan] -> [Belzutifan monotherapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Mapping,RxNorm - ATC,RxNorm to ATC,"This connects a drug product to an ATC class, not an ingredient, so it is often coarser than its near-equivalence label suggests. Its own example shows a 7-ingredient combination product mapping to the ATC class of just one of those ingredients, which is not a faithful equivalence. Example: [AESCULUS HIPPOCASTANUM SEED OIL / cupric acetate / Epinephrine / Ergot Alkaloids / Helleborus niger whole extract / Potentilla anserina whole extract / tobacco leaf extract Oral Solution] -> [epinephrine; local oral]","Connects ATC Classes and Drug Products, but not Ingredients",https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-ATC#other-information -Identity,Exact equivalence,RxNorm - ATC name,RxNorm to ATC equivalent by concept_name (OMOP),"Explicitly tagged as equivalent, representing exact semantic identity.",, -Identity,Near equivalence,RxNorm - ATC pr lat,RxNorm/Extension to ATC pr lateral (OMOP),Lateral cross-domain link (primary active ingredient to class). Example: [Umifenovir] -> [umifenovir; oral],"Connects Monocomponent ATC Class with its full semantic equivalent in the form of the main active Standard Ingredient. -Connects the 1st active ingredient of the ATC Polycomponent ATC Class with Standard Ingredient. -Can be used for a group of similar ingredients (e.g. senna glycosides, estrogens)",https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-ATC#other-information -Identity,Near equivalence,RxNorm - ATC sec lat,RxNorm/Extension to ATC secondary lateral (OMOP),"Lateral cross-domain link (primary active ingredient to class). Example: [rubella virus vaccine live (Wistar RA 27-3 strain)] -> [measles, combinations with mumps and rubella, live attenuated; parenteral]",Connects the 2nd active ingredient OR any subsequent active ingredient of a Multicomponent ATC Class to Rx/RxE Ingredient.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-ATC#other-information -Hierarchy,Categorical – up,RxNorm - ATC sec up,RxNorm/Extension to ATC secondary upwards (OMOP),"Structural map rolling an RxNorm ingredient up to a broader ATC class. Example: [meningococcal group A polysaccharide] -> [tetanus toxoid, combinations with diphtheria toxoid; parenteral]",Connects the 2nd Ingredient Group OR any subsequent Ingredient Group OR NOT mentioned and unspecified list of Ingredients which can be in combination with Primary lateral or Primary upward ATC Classes to respective Rx/RxN Ingredients.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-ATC#other-information -Association,Therapeutic,RxNorm - CVX,RxNorm - CVX (OMOP),Links therapeutic interventions to clinical indications or contraindications. Example: [hepatitis B immune globulin] -> [hepatitis B immune globulin],, -Composition,Logical,RxNorm - DOI,RxNorm contained in DOI (OMOP),"Conceptual grouping/aggregation of entities (e.g., Q&A, HOI, SMQ, Schema). Example: [thioridazine] -> [OMOP Typical Antipsychotics]",, -Identity,Exact equivalence,RxNorm - ETC name,RxNorm to ETC equivalent by concept_name (OMOP),"Explicitly tagged as equivalent, representing exact semantic identity.",, -Association,Therapeutic,RxNorm - SPL,RxNorm to SPL (NLM),Links therapeutic interventions to clinical indications or contraindications. Example: [0.5 ML hepatitis B immune globulin 220 UNT/ML Prefilled Syringe [Hyperhep B]] -> [hepatitis b immune globulin (human) 220[iU]/mL INTRAMUSCULAR INJECTION [hyperhep b s/d]],, -Attribute,Pharmaceutical,RxNorm dose form of,Dose form of (RxNorm),Qualifies physical drug delivery properties or dose forms. Example: [Vaginal Mechanical Pessaries with Substance] -> [estradiol 0.025 MG Vaginal Mechanical Pessaries with Substance Box of 15],Links specific pharmaceutical dose form to a drug.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Attribute,Pharmaceutical,RxNorm has dose form,Has dose form (RxNorm),Qualifies physical drug delivery properties or dose forms. Example: [hepatitis B immune globulin 200 UNT/ML Injectable Solution] -> [Injectable Solution],Links a drug to its specific pharmaceutical dose form.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Identity,Version lineage,RxNorm replaced by,Concept replaced by (RxNorm),Handles lifecycle transitions and deprecated concept history.,, -Identity,Version lineage,RxNorm replaces,Concept replaces (RxNorm),Handles lifecycle transitions and deprecated concept history.,, -Hierarchy,Categorical – up,Scale type of,Scale type of,Assigns a concept to a high-level grouping or classification chapter. Example: [Multi] -> [Body measurements],, -Identity,Near equivalence,Schema to ICDO,Schema to ICDO Condition (NAACCR),"Links commercial or contextually similar entities (e.g., brand names, biosimilars, external maps). Example: [Malignant Melanoma of Maxillary Sinus] -> [Malignant melanoma, NOS, of maxillary sinus]",, -Attribute,Measurement,Schema to Value,Schema to Value (NAACCR),"Defines metrics, scales, units, and quantitative properties of an observation. Example: [Malignant Melanoma of Maxillary Sinus] -> [Level II lymph node(s) involved]",, -Composition,Logical,Schema to Variable,Schema to Variable (NAACCR),"Conceptual grouping/aggregation of entities (e.g., Q&A, HOI, SMQ, Schema). Example: [Malignant Melanoma of Maxillary Sinus] -> [Path Date Spec Collect 2]",, -Composition,Logical,SMQ - MedDRA,SMQ consists of MedDRA (MedDRA),"Conceptual grouping/aggregation of entities (e.g., Q&A, HOI, SMQ, Schema).",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Composition,Logical,SNOMED - HOI,SNOMED contained in HOI (OMOP),"Conceptual grouping/aggregation of entities (e.g., Q&A, HOI, SMQ, Schema). Example: [Chronic peptic ulcer with hemorrhage AND with perforation but without obstruction] -> [OMOP Upper GastrointestinaI Ulcer 2]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-SNOMED#equivalent-and-categorical-relationships -Association,Mapping,SNOMED - OPCS4,SNOMED to OPCS4 equivalent or categorical (NHS),"Per its own r_name, this bundles true 1:1 equivalences together with loose categorical or broader mappings, used when no clean OPCS4-to-SNOMED equivalent exists. It is a no-clean-mapping fallback, not a trustworthy equivalence.",, -Hierarchy,Categorical – up,SNOMED cat - CPT4,SNOMED category to CPT-4 (OMOP),"Assigns a concept to a high-level grouping or classification chapter. Example: [Hospital admission] -> [Induced abortion, by 1 or more intra-amniotic injections (amniocentesis-injections), including hospital admission and visits, delivery of fetus and secundines; with hysterotomy (failed intra-amniotic injection)]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-SNOMED#equivalent-and-categorical-relationships -Association,Administrative,SNOMED proc - HCPCS,SNOMED procedure subsumes HCPCS (OMOP),"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Association,Diagnostic,Specimen of,Specimen of (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Tissue specimen from left breast] -> [Mammography of left breast specimen]",Indicates the type of specimen on which a measurement or observation is performed.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/evaluation-procedure-attributes#has-specimen -Attribute,Anatomical,Specimen topo of,Specimen source topography of (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Structure of right cerebellar hemisphere] -> [Tissue specimen from right hemisphere of cerebellum]",, -Attribute,Temporal,Start date of,Start date of,"Qualifies clinical events by their timing, sequence, or duration. Example: [RX Date BRM] -> [RX Summ--BRM]",, -Association,Therapeutic,Steroid tx of,Steroid therapy of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Dexamethasone] -> [Selinexor and Dexamethasone (Sd)],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Hierarchy,Categorical – up,Subject matter of,Subject matter domain of (LOINC),Assigns a concept to a high-level grouping or classification chapter. Example: [Oncology] -> [Oncology Plan of care note],,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Attribute,Procedural,Subst used by,Substance used by (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Vitamin supplement] -> [Vitamin supplement therapy],,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Attribute,Pharmaceutical,Surf character of,Surface characteristic of (SNOMED),Qualifies physical drug delivery properties or dose forms. Example: [Saline-filled smooth breast implant] -> [Smooth],, -Association,Therapeutic,Synth regimen of,Synthetic regimen of(HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [PCR (rituximab-abbs)] -> [PCR],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Clinical,Targ population of,Target population of (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Adult population] -> [Hepatitis A virus antigen only adult vaccine product]",, -Identity,Near equivalence,Trade family grp of,Trade family group of (SNOMED),"Links commercial or contextually similar entities (e.g., brand names, biosimilars, external maps).",, -Identity,Near equivalence,Tradename of,Tradename of (RxNorm),"Links commercial or contextually similar entities (e.g., brand names, biosimilars, external maps). Example: [0.5 ML hepatitis B immune globulin 217 UNT/ML Prefilled Syringe [Bayhep B]] -> [0.5 ML hepatitis B immune globulin 217 UNT/ML Prefilled Syringe]",Navigation link between Clinical (Generic) and Branded concepts.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Association,Mechanistic,Transcribes to,Genomic DNA transcribes to mRNA,Links substances to functional biological pathways or mechanisms. Example: [HRAS on GRCh38 chr11: Deletion in position 534287 to 534288 and Insertion of TT measurement] -> [HRAS transcript: Deletion in position 35 to 36 and Insertion of AA measurement],, -Association,Mechanistic,Translates to,mRNA Translates to protein,Links substances to functional biological pathways or mechanisms. Example: [APC transcript: Deletion in position 3317 measurement] -> [APC protein: Frameshift substitution in position 1106 of G replaced by E followed by a new reading frame extending for 20 amino acids measurement],, -Association,Administrative,Typ bill - Freq Code,Typ bill Full to Frequency code (UB04),"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Association,Administrative,Typ bill - Typ bill3,Typ bill Full to Typ bill 3 digits (UB04),"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Identity,Exact equivalence,Type mapped from,Type concept to non-standard map (OMOP),Direct semantic identity or mapping rule across different vocabularies.,, -Hierarchy,Categorical – up,Type of,Type of,Assigns a concept to a high-level grouping or classification chapter. Example: [Numeric] -> [Size of Lymph Nodes],, -Association,Administrative,Type of service of,Type of service of (LOINC),"Operational link for system billing, metadata, dictionaries, or encounter tracking. Example: [Visit notification] -> [Visit notification]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Identity,Version lineage,UCUM replaces,Concept replaces (UCUM),Handles lifecycle transitions and deprecated concept history.,,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Identity,Exact equivalence,Unit mapped from,Unit concept to non-standard map (OMOP),Direct semantic identity or mapping rule across different vocabularies. Example: [counts per minute] -> [Bicycle speed],,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Attribute,Measurement,Unit of presen of,Unit of presentation of (SNOMED),"Defines metrics, scales, units, and quantitative properties of an observation. Example: [Actuation] -> [Product containing precisely fluticasone propionate 125 microgram and salmeterol (as salmeterol xinafoate) 25 microgram/actuation pressurized conventional release suspension for inhalation]",, -Association,Administrative,Unit of prod use of,Unit relating to the entity that can be handled of (DM+D),"Operational link for system billing, metadata, dictionaries, or encounter tracking. Example: [Tablet - unit of product usage] -> [Levothyroxine sodium 25 microgram oral tablet]",, -Attribute,Procedural,Using acc device,Using access device (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Fibreoptic endoscopic coagulation of oesophageal lesion haemorrhage] -> [Endoscope]",Using access device (attribute) specifies the instrument or equipment used to access the site of a procedure.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#using-access-device -Attribute,Procedural,Using energy,Using energy (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Photodynamic laser therapy to lesion of skin] -> [Light]",Using energy (attribute) refers to the energy used to execute an action., -Association,Diagnostic,Using finding inform,Using finding informer (SNOMED),Links diagnostic evidence or tests to a clinical concept/state. Example: [Urinary symptoms] -> [Subject of record or other provider of history],Specifies the person or other entity from which the clinical finding information was obtained. It is not about the particular individual but about the category or type of informer. It is used to differentiate patient-reported symptoms from provider-determined signs.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#finding-informer -Association,Diagnostic,Using finding method,Using finding method (SNOMED),Links diagnostic evidence or tests to a clinical concept/state. Example: [Abnormal breath sounds] -> [Auscultation],Specifies the means by which a clinical finding was determined. It includes findings that were determined by examination of the patient.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#finding-method -Attribute,Procedural,Using subst,Using substance (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Tc-99 MIBI (technetium 99m methoxyisobutylisonitrile) parathyroid subtraction study] -> [Radioactive isotope],Using substance (attribute) describes the Substance used to execute the action of a procedure. ,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#using-substance -Hierarchy,Categorical – up,Vaccine group of,Vaccine group of (CVX),"Assigns a concept to a high-level grouping or classification chapter. Example: [Anthrax vaccine, unspecified] -> [Anthrax vaccine, adsorbed, adjuvanted, post-exposure prophylaxis]",, -Identity,Exact equivalence,Value mapped from,Value_as_concept to non-standard map (OMOP),Direct semantic identity or mapping rule across different vocabularies. Example: [Hospice] -> [Patients who use hospice services any time during the measurement period],, -Attribute,Measurement,Value of,Value of,"Defines metrics, scales, units, and quantitative properties of an observation. Example: [refractometry result error: "" 2""] -> [Refractometry result unreliable (right)]",, -Hierarchy,Categorical – up,Value to Schema,Value to Schema (NAACCR),Assigns a concept to a high-level grouping or classification chapter. Example: [Skin@None; no surgery of primary site; autopsy ONLY] -> [Skin],, -Identity,Exact equivalence,VAProd - NDFRT,VA Product to NDF-RT (NDF-RT),Direct semantic identity or mapping rule across different vocabularies.,, -Attribute,Temporal,After,After (SNOMED),"Qualifies clinical events by their timing, sequence, or duration. Example: [Transplant of kidney] -> [Pre-existing disease in renal transplant]","used to model concepts in which a clinical finding occurs after another clinical finding, procedure or event.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#after -Attribute,Pharmaceutical,AMP of,Actual Medicinal Product of (SNOMED),Qualifies physical drug delivery properties or dose forms.,, -Composition,Logical,Answer of (PPI),Answer of (PPI),"Conceptual grouping/aggregation of entities (e.g., Q&A, HOI, SMQ, Schema). Example: [1] -> [How many doses of the vaccine did you receive?]",, -Association,Therapeutic,Anticoag tx of,Anticoag tx of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Anagrelide] -> [Anagrelide monotherapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Antineoplastic of,Antineoplastic of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Alitretinoin] -> [Alitretinoin monotherapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Procedural,Approach of,Approach of (SNOMED),"Defines execution technicalities like route of administration, method, or device used.",, -Identity,Near equivalence,ATC - RxNorm pr lat,ATC to RxNorm/Extension primary lateral (OMOP),Lateral cross-domain link (class to primary active ingredient). Example: [moxidectin; systemic] -> [moxidectin],"Connects Monocomponent ATC Class with its full semantic equivalent in the form of the main active Standard Ingredient. -Connects the 1st active ingredient of the ATC Polycomponent ATC Class with Standard Ingredient. -Can be used for a group of similar ingredients (e.g. senna glycosides, estrogens)",https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-ATC#other-information -Hierarchy,Categorical – down,ATC - RxNorm pr up,ATC to RxNorm/Extension primary upwards (OMOP),Structural map expanding an ATC class to its specific RxNorm ingredient. Example: [vaginal ring with progestogen; vaginal] -> [allylestrenol],"Connects the 1st Ingredient Group of a Multicomponent ATC Class to respective Rx/RxE Ingredients -Applied to a Multicomponent ATC Class, whose name starts either from an Ingredient Group or the word of ‘^combinations’, or contains only one Ingredient Group, which is mentioned somewhere in the middle -Can be used for well-defined but diverse group of ingredients (e.g. diastase, tree pollen, feather)",https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-ATC#other-information -Identity,Near equivalence,ATC - RxNorm sec lat,ATC to RxNorm/Extension secondary lateral (OMOP),Lateral cross-domain link (class to primary active ingredient). Example: [valsartan and nebivolol; oral] -> [nebivolol],Connects the 2nd active ingredient OR any subsequent active ingredient of a Multicomponent ATC Class to Rx/RxE Ingredient.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-ATC#other-information -Hierarchy,Categorical – down,ATC - RxNorm sec up,ATC to RxNorm/Extension secondary upwards (OMOP),"Structural map expanding an ATC class to its specific RxNorm ingredient. Example: [econazole, combinations; systemic, vaginal] -> [hydrocortisone]",Connects the 2nd Ingredient Group OR any subsequent Ingredient Group OR NOT mentioned and unspecified list of Ingredients which can be in combination with Primary lateral or Primary upward ATC Classes to respective Rx/RxN Ingredients.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-ATC#other-information -Identity,Exact equivalence,ATC - SNOMED eq,ATC to SNOMED equivalent (RxNorm),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [gallium (68Ga) gozetotide] -> [Gallium (68-Ga) dotatate]","Connects ATC Classes and SNOMED Pharma or Biological Products, Physical Objects and Substances",https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-ATC#other-information -Hierarchy,Categorical – up,Available as box,Is available in a prepackaged box (OMOP),Assigns a concept to a high-level grouping or classification chapter. Example: [hepatitis B immune globulin 200 UNT/ML Injectable Solution] -> [hepatitis B immune globulin 200 UNT/ML Injectable Solution Box of 1],, -Attribute,Pharmaceutical,Basic dose form of,Basic dose form of (SNOMED),Qualifies physical drug delivery properties or dose forms. Example: [Implant dosage form] -> [Prolonged-release ocular implant],,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/pharmaceutical-and-biologic-product/index/index/pharmaceutical-dose-form-naming-and-modeling-conventions -Attribute,Temporal,Before,Before (SNOMED),"Qualifies clinical events by their timing, sequence, or duration. Example: [Pre-existing disease in renal transplant] -> [Transplant of kidney]","is used to model pre-procedure complications (e.g., preoperative complication). It represents temporal associations between procedures and related disorders",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#before -Identity,Near equivalence,Brand name of,Brand name of (OMOP),"Links commercial or contextually similar entities (e.g., brand names, biosimilars, external maps). Example: [Bylvay] -> [odevixibat 0.2 MG Oral Capsule [Bylvay] by Albireo AB]",, -Attribute,Measurement,CAP value of,CAP value of,"Defines metrics, scales, units, and quantitative properties of an observation.",, -Hierarchy,Categorical – up,CI chem class of,Contraindicating chemical class of (NDF-RT),Assigns a concept to a high-level grouping or classification chapter. Example: [Insulin] -> [CISAPRIDE 10MG TAB],, -Attribute,Clinical,Clinical course of,Has clinical course of (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Brittle course] -> [Brittle asthma]",used to represent both the course and onset of a disease or condition,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#clinical-course -Composition,Structural,Coating material of,Coating material of (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Polyurethane foam] -> [Silicone gel-filled polyurethane foam covered surface breast implant],Represents the material with which an object is coated,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/physical-object/physical-object-defining-attributes#has-coating-material -Composition,Physical,Comp material of,Compositional material of (SNOMED),Physical material assembly or structural part-whole relationship. Example: [Silicone foam] -> [Silicone foam cavity wound dressing],Represents the site where the device is intended to reside in or on the body.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/physical-object/physical-object-defining-attributes#has-compositional-material -Composition,Structural,Has conc denom unit,Has concentration strength denominator unit (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Calcium oxybate 500 mg/mL and magnesium oxybate 500 mg/mL and potassium oxybate 500 mg/mL and sodium oxybate 500 mg/mL oral solution] -> [mL],, -Composition,Structural,Has conc num unit,Has concentration strength numerator unit (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Calcium oxybate 500 mg/mL and magnesium oxybate 500 mg/mL and potassium oxybate 500 mg/mL and sodium oxybate 500 mg/mL oral solution] -> [mg],, -Composition,Structural,Has count of ing,Has count of base of active ingredient (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Polyvinyl alcohol 1.4% conventional release eye drops] -> [One],, -Association,Therapeutic,Has cytotox chemo Rx,Has cytotoxic chemotherapy - RxNorm (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [AC-D] -> [cyclophosphamide],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Has cytotoxic chemo,Has cytotoxic chemotherapy (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [AC-D] -> [Doxorubicin],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Procedural,Has dir device,Has direct device (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Renewal of single chamber intravenous cardiac pacemaker system] -> [Cardiac pacemaker]",Direct device (attribute) represents the device on which the method directly acts.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#direct-device -Attribute,Clinical,Has disc indicator,Has discontinued indicator (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators.",, -Attribute,Pharmaceutical,Has disposition,Has disposition (SNOMED),Qualifies physical drug delivery properties or dose forms. Example: [Ergopeptide] -> [Toxin],, -Attribute,Pharmaceutical,Has dose form group,Has dose form group (RxNorm),Qualifies physical drug delivery properties or dose forms. Example: [odevixibat Oral Product] -> [Oral Product],, -Association,Causal,Has due to,Has due to (SNOMED),"Relates conditions to their biological, pathological, or environmental causes. Example: [Congenital macrostomia] -> [Congenital failure of fusion between maxillary and mandibular processes]","to identify a clinical finding/disorder, event, or procedure concept as the direct cause of another Clinical finding or Disorder concept",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#due-to -Association,Therapeutic,Has EMA indication,Has EMA indication (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Asparaginase erwinia chrysanthemi-rywn] -> [Acute lymphoblastic leukemia],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Has endocrine tx Rx,Has endocrine therapy - RxNorm (HemOnc),"Links therapeutic interventions to clinical indications or contraindications. Example: [Anastrozole, Lapatinib, Trastuzumab] -> [anastrozole]",,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Clinical,Has episodicity,Has episodicity (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Intrathecal Ziconotide pump trial] -> [New episode]","represent episodes of care provided by a physician or other healthcare provider, not episodes of disease experienced by the patient",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#episodicity -Association,Causal,Has etiology,Has Etiology (SNOMED),"Relates conditions to their biological, pathological, or environmental causes.",, -Composition,Structural,Has excipient,Has excipient (SNOMED),Defines the inherent chemical constituents or dosage strength.,, -Attribute,Anatomical,Has extent,Has Extent (SNOMED),"Spatial qualifier describing location, morphology, or extent.",, -Association,Administrative,Has FDA labeling,Has FDA labeling (HemOnc),"Operational link for system billing, metadata, dictionaries, or encounter tracking. Example: [Belzutifan] -> [Mutation-specific]",, -Association,Therapeutic,Has FDA-appr ind,Has FDA-approved drug indication (FDB),Links therapeutic interventions to clinical indications or contraindications.,, -Attribute,Measurement,Has flavor,Has flavor (SNOMED),"Defines metrics, scales, units, and quantitative properties of an observation.",, -Attribute,Anatomical,Has focus,Has focus (SNOMED),"Spatial qualifier describing location, morphology, or extent. Example: [Fibreoptic endoscopic coagulation of oesophageal lesion haemorrhage] -> [Bleeding]",Has focus (attribute) specifies the Clinical finding or Procedure which is the focus of a procedure. This attribute is self-grouped.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#has-focus -Association,Administrative,Has free indicator,Has NHS dm+d freeness indicator,"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Association,Therapeutic,Has growth factor,Has growth factor (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [FLAG-Ida and GO] -> [Lenograstim],, -Association,Therapeutic,Has growth factor Rx,Has growth factor Rx (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [FLAG-Ida and GO] -> [lenograstim],, -Association,Therapeutic,Has HC indication,Has HC indication (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Amivantamab] -> [Non-small cell lung cancer],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Has immunosuppr Rx,Has immunosuppressor - RxNorm (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Iptacopan monotherapy] -> [eculizumab],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Has immunosuppressor,Has immunosuppressor (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Iptacopan monotherapy] -> [Iptacopan],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Has immunotherapy,Has immunotherapy (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Brexucabtagene autoleucel monotherapy] -> [Brexucabtagene autoleucel],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Has immunotherapy Rx,Has immunotherapy - RxNorm (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Brexucabtagene autoleucel monotherapy] -> [brexucabtagene autoleucel],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Administrative,Has incipient,Has incipient (DM+D),"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Hierarchy,Categorical – down,Has inferred class,Has inferred drug class (OMOP),Expands a classification group into its constituent members.,, -Attribute,Context,Has inherent,Has inherent (SNOMED),"This is the inverse of Inheres in, which is already classified as Attribute, Context. It is classified here by precedent from its sibling row. Example: [Proximal surgical margin] -> [Presence of primary malignant neoplasm of esophagus at proximal surgical margin in esophageal resection specimen]",Specifies the independent continuant in which the quality exists and on which the dependent quality (of this observable) depends.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/observable-entity/observable-entity-defining-attributes#inheres-in -Attribute,Clinical,Has intent,Has intent (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Mild cognitive impairment annual review] -> [Preventive intent]","The Has intent attribute is used when a procedure may be performed for various reasons, described with subtypes of 363675004 |Intents (nature of procedure values) (qualifier value)|, such as diagnostic, palliative, preventive, therapeutic, etc. These intents are not used to define procedures with intents that are inherent to the procedure; such as biopsies that are by definition diagnostic, or fracture fixations which are always therapeutic.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#has-intent -Association,Interaction,Has interprets,Has interprets (SNOMED),Defines safety or therapeutic interference between agents. Example: [Sickle cell-hemoglobin SS disease] -> [Measurement of total hemoglobin concentration],"Refers to the entity being evaluated or interpreted, when an evaluation, interpretation, or judgment is intrinsic to the meaning of a concept.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#interprets -Hierarchy,Categorical – up,Has Issue,Has Issue,"Assigns a concept to a high-level grouping or classification chapter. Example: [Additional help outside the hospital (e.g. help with daily activities, self help groups, district nurses)?] -> [receiving information about additional help outside the hospital]",, -Attribute,Procedural,Has licensed route,Has licensed route (DM+D),"Defines execution technicalities like route of administration, method, or device used.",, -Association,Therapeutic,Has local therap Rx,Has local therapy - RxNorm (HemOnc),"Links therapeutic interventions to clinical indications or contraindications. Example: [Doxorubicin, Pegaspargase, Vincristine, Prednisone] -> [methotrexate]",,https://hemonc.org/wiki/HemOncKB_canonical_triples -Composition,Physical,Has meas component,Has Measured Component (SNOMED),Physical material assembly or structural part-whole relationship.,, -Attribute,Measurement,Has measurement,Has measurement method (SNOMED),"Defines metrics, scales, units, and quantitative properties of an observation. Example: [Herpes simplex antigen, enzyme immunoassay] -> [Immunoassay, quantitative]",, -Composition,Structural,Has modification,Has modification of (SNOMED),"A salt/ester/hydrate structural relationship between a substance and its modified form, not a deprecated-concept link. Example: [Berotralstat] -> [Berotralstat hydrochloride]",, -Hierarchy,Categorical – up,Has Module,Has Module,Assigns a concept to a high-level grouping or classification chapter. Example: [Hospital admission] -> [SNOMED CT core],, -Association,Therapeutic,Has NMPA indication,Has NMPA indication (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Tislelizumab] -> [Non-small cell lung cancer squamous],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Causal,Has pathology,Has pathological process (SNOMED),"Relates conditions to their biological, pathological, or environmental causes. Example: [Hereditary elliptocytosis] -> [Pathological developmental process]",, -Association,Administrative,Has pept-drg cjg Rx,Has peptide-drug conjugate - RxNorm (HemOnc),"Operational link for system billing, metadata, dictionaries, or encounter tracking. Example: [Melphalan flufenamide and Dexamethasone] -> [melphalan flufenamide]",,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Has pept-drug cjgt,Has peptide-drug conjugate (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Melphalan flufenamide and Dexamethasone] -> [Melphalan flufenamide],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Composition,Containment,Has pharma prep,Has pharmaceutical preparation (NDF),Links items to their physical packaging or multi-item kit groupings.,, -Association,Mechanistic,Has physio effect,Has physiological effect (NDF-RT),"Links substances to functional biological pathways or mechanisms. Example: [HYDROGEN PEROXIDE 3% SPRAY,TOP] -> [Unknown Physiological Effect]",, -Association,Mechanistic,Has physiol state,Has physiologic state (SNOMED Vet),Links substances to functional biological pathways or mechanisms. Example: [Grower/finisher pig for meat production] -> [Physically growing],, -Association,Mechanistic,Has PK,Has pharmacokinetics (NDF-RT),Links substances to functional biological pathways or mechanisms. Example: [ACETAMINOPHEN 325MG CAP] -> [Hepatic Metabolism],, -Association,Therapeutic,Has PMDA indication,Has PMDA indication (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Sutimlimab] -> [Cold agglutinin disease],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Temporal,Has precondition,Has precondition (SNOMED),"Qualifies clinical events by their timing, sequence, or duration. Example: [Recumbent body height] -> [Recumbent body position]","Specify body state, timing, challenges, or other situations that must be true of the entity to be observed.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/observable-entity/observable-entity-defining-attributes#precondition -Attribute,Procedural,Has proc device,Has procedure device (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Cardiac implantable electronic device reprogramming due to surgery] -> [Cardiac implant]","Procedure device (attribute) is used to model devices associated with a procedure. This attribute is used to define high-level, general concepts that aggregate procedures according to the device involved.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#procedure-device -Association,Diagnostic,Has proc duration,Process duration,"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Measured quantity of intake of potassium in 24 hours] -> [24 hours]",, -Association,Diagnostic,Has process output,Has process output (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Cardiac rate] -> [Entire cardiac cycle process]",, -Attribute,Pharmaceutical,Has prod character,Has product characteristic (SNOMED),Qualifies physical drug delivery properties or dose forms.,, -Attribute,Measurement,Has property,Has property,"Defines metrics, scales, units, and quantitative properties of an observation. Example: [Abnormal lymphocyte count] -> [Number concentration]",, -Attribute,Measurement,Has property type,Has property type,"Defines metrics, scales, units, and quantitative properties of an observation. Example: [Non-invasive central blood pressure] -> [Pressure]",, -Attribute,Pharmaceutical,Has quantified form,Has quantified form (RxNorm),Qualifies physical drug delivery properties or dose forms. Example: [hepatitis B immune globulin 200 UNT/ML Injectable Solution] -> [1 ML hepatitis B immune globulin 200 UNT/ML Injectable Solution],Modifies a drug concept with a specific quantity/strength.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Association,Administrative,Has question source,Has question source,"Operational link for system billing, metadata, dictionaries, or encounter tracking. Example: [Who do you know who has died? Please specify.] -> [Nurse’s Health Study COVID-19 supplement]",, -Association,Therapeutic,Has radiocjgt Rx,Has radioconjugate - RxNorm (HemOnc),"Links therapeutic interventions to clinical indications or contraindications. Example: [Busulfan, Fludarabine, Ibritumomab tiuxetan] -> [ibritumomab tiuxetan]",,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Has radiotherapy,Has radiotherapy (HemOnc),"Links therapeutic interventions to clinical indications or contraindications. Example: [Capecitabine, Erlotinib, RT] -> [External beam radiotherapy]",,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Has radiotherapy Rx,Has radiotherapy - RxNorm (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Radioiodine and Selumetinib] -> [iodine],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Composition,Physical,Has relative part,Has relative part (SNOMED),Physical material assembly or structural part-whole relationship. Example: [Toe brachial pressure index] -> [Structure of brachial artery],, -Attribute,Pharmaceutical,Has release charact,Has dose form release characteristic (SNOMED),Qualifies physical drug delivery properties or dose forms. Example: [Gas for insufflation] -> [Conventional release],, -Attribute,Clinical,Has revision status,Has revision status (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Primary open reduction of fracture of short bone and fixation using screw] -> [Primary operation]",Revision status (attribute) refers to another procedure performed on the same site for the same condition. A procedure without a revision status is considered to be performed for the first time. ,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#revision-status -Attribute,Context,Has role,Has role (LOINC),Specific situational modifier or contextual role. Example: [Attending COVID-19 Intubation Consultation note] -> [Attending],,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Composition,Structural,Has setting,Has setting (LOINC),Defines the inherent chemical constituents or dosage strength. Example: [Emergency department COVID-19 Initial Evaluation form] -> [Emergency department],,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Association,Diagnostic,Has specimen,Has specimen (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Microscopic examination of cervical Papanicolaou smear and Human papillomavirus deoxyribonucleic acid detection cotesting] -> [Specimen from female genital tract]",Indicates the type of specimen on which a measurement or observation is performed.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/evaluation-procedure-attributes#has-specimen -Association,Administrative,Has specimen source,Has specimen source identity (SNOMED),"Operational link for system billing, metadata, dictionaries, or encounter tracking. Example: [Specimen from domestic vacuum cleaner filter] -> [Filter]","Specifies the type of individual, group, or physical location from which a specimen is collected",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/specimen/specimen-defining-attributes#specimen-source-identity -Composition,Structural,Has specimen subst,Has specimen substance (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Condiment] -> [Food],"Specifies the type of substance, pharmaceutical/biologic product, or physical object of which a specimen is comprised.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/specimen/specimen-defining-attributes#specimen-substance -Identity,Near equivalence,Has standard brand,Has standard Brand Name (OMOP),"Links commercial or contextually similar entities (e.g., brand names, biosimilars, external maps).",, -Attribute,Pharmaceutical,Has state of matter,Has state of matter (SNOMED),Qualifies physical drug delivery properties or dose forms. Example: [Extended-release bead implant] -> [Solid],, -Attribute,Clinical,Has status,Has status,"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Hospital admission] -> [Primitive]",, -Attribute,Anatomical,Has surface texture,Has surface texture (SNOMED),"Spatial qualifier describing location, morphology, or extent. Example: [Saline-filled smooth breast implant] -> [Smooth]",Represents the tactile characteristic or appearance of the surface of an object.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/physical-object/physical-object-defining-attributes#has-surface-texture -Attribute,Procedural,Has surgical appr,Has surgical approach (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Cystourethroscopy with ureteral meatotomy] -> [Transurethral approach]",, -Association,Therapeutic,Has targeted tx Rx,Has targeted therapy - RxNorm (HemOnc),"Links therapeutic interventions to clinical indications or contraindications. Example: [Anastrozole, Lapatinib, Trastuzumab] -> [trastuzumab]",,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Diagnostic,Has temp finding,Has temporal finding (SNOMED),Links diagnostic evidence or tests to a clinical concept/state. Example: [Self poisoning by carbon monoxide] -> [Suicide attempt by carbon monoxide inhalation],Applies to perioperative complications and clinical findings where there is no causal relationship but a time-relative association exists.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#temporally-related-to -Attribute,Temporal,Has time aspect,Has time aspect,"Qualifies clinical events by their timing, sequence, or duration. Example: [SARS-CoV-2 (COVID-19) Ab [Presence] in Serum or Plasma by Immunoassay] -> [Point in time (spot)]",, -Hierarchy,Categorical – up,Has Topic,Has Topic,Assigns a concept to a high-level grouping or classification chapter.,, -Attribute,Anatomical,Has Topography ICDO,Has Topography ICDO,"Spatial qualifier describing location, morphology, or extent. Example: [Carcinoma, metastatic, NOS, of lymph nodes of inguinal region or leg] -> [Lymph nodes of inguinal region or leg]",, -Identity,Near equivalence,Has trade family grp,Has trade family group (SNOMED),"Links commercial or contextually similar entities (e.g., brand names, biosimilars, external maps).",, -Identity,Near equivalence,Has tradename,Has tradename (RxNorm),"Links commercial or contextually similar entities (e.g., brand names, biosimilars, external maps). Example: [hepatitis B immune globulin 217 UNT/ML] -> [hepatitis B immune globulin 217 UNT/ML [Bayhep B]]",Reciprocal of Tradename of.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Attribute,Pharmaceutical,Has transformation,Has dose form transformation (SNOMED),"A pharmaceutical dose-form attribute, matching the existing Dose form of/Has dose form family. Example: [Gas for insufflation] -> [No transformation]",, -Association,Administrative,Has type of service,Has type of service (LOINC),"Operational link for system billing, metadata, dictionaries, or encounter tracking. Example: [COVID-19 Intubation Consultation note] -> [COVID-19]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Attribute,Procedural,Has unit of admin,Has unit of administration (SNOMED),"Defines execution technicalities like route of administration, method, or device used.",, -Association,Administrative,Has unit of prod use,Has unit relating to the entity that can be handled (DM+D),"Operational link for system billing, metadata, dictionaries, or encounter tracking. Example: [Levothyroxine sodium 50 microgram oral tablet] -> [Tablet - unit of product usage]",, -Hierarchy,Categorical – up,Has vaccine group,Has vaccine group (CVX),"Assigns a concept to a high-level grouping or classification chapter. Example: [Anthrax vaccine, adsorbed, adjuvanted, post-exposure prophylaxis] -> [Anthrax vaccine, unspecified]",, -Attribute,Measurement,Has Value,Has Value,"Defines metrics, scales, units, and quantitative properties of an observation. Example: [Cancer code, self-reported] -> [salivary gland cancer]",, -Association,Administrative,HCPCS - SNOMED meas,HCPCS is a SNOMED measurement (OMOP),"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Association,Therapeutic,ICD9CM - FDB Ind,ICD-9-CM to FDB Indication/Contraindication (FDB),Links therapeutic interventions to clinical indications or contraindications.,, -Identity,Version lineage,ICD9P replaced by,Concept replaced by (ICD-9-Procedure),Handles lifecycle transitions and deprecated concept history.,, -Identity,Version lineage,ICD9P replaces,Concept replaces (ICD-9-Procedure),Handles lifecycle transitions and deprecated concept history.,, -Association,Therapeutic,Immunosuppressor of,Immunosuppressor of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Belumosudil] -> [Belumosudil monotherapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Immunotherapy of,Immunotherapy of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Brexucabtagene autoleucel] -> [Brexucabtagene autoleucel monotherapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Association,Therapeutic,Ind/CI - SNOMED,Indication/Contra-indication to SNOMED,"Links therapeutic interventions to clinical indications or contraindications. Example: [Acidosis, Renal Tubular] -> [Renal tubular acidosis]",, -Attribute,Procedural,Indir device of,Indirect device of (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Stoma bag] -> [Emptying colostomy bag]","Indirect device (attribute) represents action on something that is located in or on a device, but is not directly on the device itself. This attribute is infrequently needed. When modeling, carefully consider its use.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#indirect-device -Attribute,Context,Inheres in,Inheres in (SNOMED),Specific situational modifier or contextual role. Example: [Recumbent body height] -> [Entire body as a whole],Specifies the independent continuant in which the quality exists and on which the dependent quality (of this observable) depends.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/observable-entity/observable-entity-defining-attributes#inheres-in -Attribute,Clinical,Intent of,Intent of (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Treatment intent] -> [CT guided drainage of abscess]","The Has intent attribute is used when a procedure may be performed for various reasons, described with subtypes of 363675004 |Intents (nature of procedure values) (qualifier value)|, such as diagnostic, palliative, preventive, therapeutic, etc. These intents are not used to define procedures with intents that are inherent to the procedure; such as biopsies that are by definition diagnostic, or fracture fixations which are always therapeutic.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#has-intent -Association,Interaction,Interprets of,Interprets of (SNOMED),Defines safety or therapeutic interference between agents. Example: [Ability to participate in outdoor sporting activities] -> [Does participate in outdoor sporting activities],"Refers to the entity being evaluated or interpreted, when an evaluation, interpretation, or judgment is intrinsic to the meaning of a concept.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#interprets -Association,Therapeutic,Investig use for,Investigational use for (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [High-grade glioma pediatric] -> [Tasadenoturev],, -Attribute,Clinical,Is characterized by,Is characterized by (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Direct local invasion] -> [Anatomic location directly invaded by primary malignant neoplasm of adrenal gland]","Specifies the process which the property describes, and on which the property (of this observable) depends",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/observable-entity/observable-entity-defining-attributes#characterizes -Association,Therapeutic,Is curr in adult,Is current in adult (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [AC-D] -> [Breast cancer],, -Association,Therapeutic,Is curr in ped,Is current in pediatric (HemOnc),"Links therapeutic interventions to clinical indications or contraindications. Example: [Doxorubicin, Pegaspargase, Vincristine, Prednisone] -> [B-cell acute lymphoblastic leukemia pediatric]",, -Hierarchy,Categorical – up,Is domain,Is a domain (OMOP),Assigns a concept to a high-level grouping or classification chapter.,, -Association,Therapeutic,Is off-label ind of,Is off-label indication of (FDB),Links therapeutic interventions to clinical indications or contraindications.,, -Association,Mechanistic,Is translated from,Protein is translated from mRNA,Links substances to functional biological pathways or mechanisms. Example: [EGFR protein: Deletion in position 746 to 751] -> [EGFR transcript: [Deletion in position 2235 to 2252; Substitution in position 2256 of T replaced by A]],, -Hierarchy,Categorical – up,Issue of,Issue of,Assigns a concept to a high-level grouping or classification chapter. Example: [feeling thirsty] -> [Did you feel thirsty?],, -Attribute,Pharmaceutical,Marketed form of,Marketed form of (OMOP),Qualifies physical drug delivery properties or dose forms. Example: [pyridoxine 100 MG/ML Injection by Hospira] -> [pyridoxine 100 MG/ML Injection],Marketed form of the drug.,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-RXNORM_EXTENSION#result -Attribute,Procedural,May be route of,May be route of (HemOnc),"Defines execution technicalities like route of administration, method, or device used. Example: [Intra-arterial] -> [Doxorubicin]",, -Association,Therapeutic,May prevent,May prevent (NDF-RT),"Links therapeutic interventions to clinical indications or contraindications. Example: [HYDROGEN PEROXIDE 3% SPRAY,TOP] -> [Skin Diseases, Infectious]",, -Attribute,Measurement,Measurement of,Measurement method of (SNOMED),"Defines metrics, scales, units, and quantitative properties of an observation. Example: [Immunoblot assay] -> [Measurement of microbial antibody using immunoblot]",, -Composition,Logical,MedDRA - HOI,MedDRA contained in HOI (OMOP),"Conceptual grouping/aggregation of entities (e.g., Q&A, HOI, SMQ, Schema).",, -Association,Mapping,MedDRA - ICD9CM,MedDRA to ICD-9-CM (MSSO),"This bridges adverse-event terminology (MedDRA) and diagnosis codes (ICD9CM), two systems built for different purposes and at different granularity. It is a lossy cross-vocabulary mapping, not a guaranteed 1:1 correspondence.",, -Composition,Logical,MedDRA - SMQ,MedDRA contained in SMQ (MedDRA),"Conceptual grouping/aggregation of entities (e.g., Q&A, HOI, SMQ, Schema).",, -Attribute,Procedural,Method of,Method of,"Defines execution technicalities like route of administration, method, or device used. Example: [Surgical rotation - action] -> [Rotational osteotomy of metatarsal]",, -Composition,Structural,Multilex ing of,Ingredient of (Multilex),Defines the inherent chemical constituents or dosage strength.,, -Identity,Exact equivalence,NDFRT - VAProd,NDF-RT to VA Product (NDF-RT),Direct semantic identity or mapping rule across different vocabularies.,, -Attribute,Pharmaceutical,NDFRT dose form of,Dose form of (NDF-RT),"Qualifies physical drug delivery properties or dose forms. Example: [Vaginal Film] -> [NONOXYNOL-9 28% FILM,VAG]",, -Attribute,Temporal,Occurs before,Occurs before (SNOMED),"Qualifies clinical events by their timing, sequence, or duration. Example: [Sickle cell-hemoglobin SS disease] -> [Acute chest syndrome]",, -Hierarchy,Taxonomic – up,OncoTree to ICDO br,OncoTree to ICDO broader,"Inheritance link generalizing to a broader parent. Example: [Lymphoid] -> [Neoplasm defined only by topography: Hematopoietic system, NOS]",, -Attribute,Pharmaceutical,Ontological form of,Ontological dose form presentation of (DM+D),Qualifies physical drug delivery properties or dose forms.,, -Hierarchy,Categorical – up,Parent item of,Parent item of (NAACCR),Assigns a concept to a high-level grouping or classification chapter. Example: [Phase III Radiation Treatment Modality] -> [Number of Phases of Rad Treatment to this Volume],, -Composition,Physical,Part of,Part of (SNOMED),Physical material assembly or structural part-whole relationship. Example: [Chromosome pair 9] -> [neurotrophic receptor tyrosine kinase 2 nucleotide sequence],, -Association,Causal,Pathology of,Pathological process of (SNOMED),"Relates conditions to their biological, pathological, or environmental causes. Example: [Contracture of joint] -> [At increased risk for joint contracture]",, -Association,Therapeutic,Pept-drug cjgt of,Peptide-drug conjugate of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Melphalan flufenamide] -> [Melphalan flufenamide and Dexamethasone],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Context,Plays role,Plays role,Specific situational modifier or contextual role. Example: [SARS-CoV-2 antigen vaccine] -> [Active immunity stimulant role],, -Attribute,Clinical,Priority of,Priority of (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Elective] -> [Elective repair of suprarenal abdominal aortic aneurysm]",Priority (attribute) is used when a procedure concept specifies a priority. This attribute is self-grouped.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#priority -Association,Diagnostic,Process acts on,Process acts on (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Measured quantity of intake of potassium in 24 hours] -> [Potassium]","Describes that a process specifically acts on some entity, e.g. by transporting that entity in or out of the body, i.e. Rate of intake of protein.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/observable-entity/observable-entity-defining-attributes#process-acts-on -Association,Diagnostic,Process output of,Process output of (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Medical food] -> [Method of administration for tube feeding of patient]",, -Attribute,Pharmaceutical,Prod character of,Product characteristic of (SNOMED),Qualifies physical drug delivery properties or dose forms.,, -Composition,Physical,Product comp of,Product component of (NDF-RT),Physical material assembly or structural part-whole relationship. Example: [IXEKIZUMAB] -> [IXEKIZUMAB 80MG/ML AUTO INJECTOR],, -Composition,Structural,Reformulation of,Reformulation of (RxNorm),"Links a reformulated drug to its earlier formulation. This is a substance or modified-form relationship, in the same family as Modification of.",, -Attribute,Clinical,Revision status of,Revision status of (SNOMED),"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [First revision] -> [Revision of lumboperitoneal shunt]",Revision status (attribute) refers to another procedure performed on the same site for the same condition. A procedure without a revision status is considered to be performed for the first time. , -Association,Therapeutic,Rx radiotherapy of,RxNorm radiotherapy of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [vipivotide tetraxetan] -> [Lutetium Lu 177 vipivotide tetraxetan monotherapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Hierarchy,Categorical – up,RxNorm - ATC pr up,RxNorm/Extension to ATC primary upwards (OMOP),"Structural map rolling an RxNorm ingredient up to a broader ATC class. Example: [meningococcal group A polysaccharide] -> [meningococcus A,C,Y,W-135, tetravalent purified polysaccharides antigen; systemic]","Connects the 1st Ingredient Group of a Multicomponent ATC Class to respective Rx/RxE Ingredients -Applied to a Multicomponent ATC Class, whose name starts either from an Ingredient Group or the word of ‘^combinations’, or contains only one Ingredient Group, which is mentioned somewhere in the middle -Can be used for well-defined but diverse group of ingredients (e.g. diastase, tree pollen, feather)",https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-ATC#other-information -Association,Therapeutic,RxNorm - ETC,RxNorm to ETC (FDB),Links therapeutic interventions to clinical indications or contraindications.,, -Identity,Exact equivalence,RxNorm - NDFRT name,RxNorm to NDF-RT equivalent by concept_name (OMOP),"Explicitly tagged as equivalent, representing exact semantic identity. Example: [antithrombin III] -> [ANTITHROMBIN III]",, -Composition,Structural,Setting of,Setting of (LOINC),Defines the inherent chemical constituents or dosage strength. Example: [Teleimaging] -> [Ophthalmology Teleimaging Consult note],,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Association,Mapping,SNOMED - ICDO,SNOMED - ICDO,"ICDO is a coarse topography and morphology coding system for cancer registries, while SNOMED is fine-grained clinical terminology. This is a lossy cross-vocabulary mapping, the same shape as OPCS4-SNOMED, not a guaranteed 1:1 correspondence.",, -Association,Therapeutic,SNOMED - ind/CI,SNOMED to Indication/Contra-indication,Links therapeutic interventions to clinical indications or contraindications. Example: [Edema of larynx] -> [Laryngeal Edema],,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-SNOMED#equivalent-and-categorical-relationships -Association,Administrative,SNOMED meas - HCPCS,SNOMED measurement subsumes HCPCS (OMOP),"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Association,Diagnostic,Specimen identity of,Specimen source identity of (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Surgical suture, device] -> [Suture material submitted as specimen]","Specifies the type of individual, group, or physical location from which a specimen is collected",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/specimen/specimen-defining-attributes#specimen-source-identity -Association,Diagnostic,Specimen proc of,Specimen procedure of (SNOMED),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Cervicovaginal lavage] -> [Cervicovaginal washout fluid sample]",Identifies the procedure by which a specimen is obtained.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/specimen/specimen-defining-attributes#specimen-procedure -Composition,Structural,Specimen subst of,Specimen substance of (SNOMED),Defines the inherent chemical constituents or dosage strength. Example: [Salsa] -> [Salsa specimen],"Specifies the type of substance, pharmaceutical/biologic product, or physical object of which a specimen is comprised.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/specimen/specimen-defining-attributes#specimen-substance -Association,Therapeutic,SPL - RxNorm,SPL to RxNorm (NLM),Links therapeutic interventions to clinical indications or contraindications. Example: [BLEOMYCIN - bleomycin injection] -> [bleomycin sulfate],, -Attribute,Pharmaceutical,State of matter of,State of matter of (SNOMED),Qualifies physical drug delivery properties or dose forms. Example: [Solid] -> [Lyophilisate],, -Attribute,Clinical,Status of,Status of,"Qualifies clinical states, statuses (e.g., severity, intent, prescribing status), or general clinical indicators. Example: [Defined] -> [Neoplasm of uncertain behavior of muscle of head]",, -Association,Diagnostic,Sub-specimen of,Sub-specimen of (SNOMED Vet),"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [Throat swab] -> [Pooled cloacal/pharyngeal swab]",, -Attribute,Context,Subject of,Subject of (SNOMED),Specific situational modifier or contextual role. Example: [Doxycycline-containing product] -> [Organism susceptibility to doxycycline],,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Association,Administrative,Supplier of,Supplier or manufacturer of (OMOP),"Operational link for system billing, metadata, dictionaries, or encounter tracking. Example: [HM3A] -> [60 ACTUAT fluticasone 0.5 MG/ACTUAT / salmeterol 0.05 MG/ACTUAT Inhalation Powder [Flusalio] by HM3A]",,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Association,Therapeutic,Supportive med of,Supportive medication of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Voxelotor] -> [Voxelotor monotherapy],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Anatomical,Surface texture of,Surface texture of (SNOMED),"Spatial qualifier describing location, morphology, or extent. Example: [Textured] -> [Saline-filled textured breast implant]",Represents the tactile characteristic or appearance of the surface of an object.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/physical-object/physical-object-defining-attributes#has-surface-texture -Attribute,Procedural,Surgical appr of,Surgical approach of (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Endonasal transsphenoidal approach] -> [Endoscopic endonasal transsphenoidal total excision of neoplasm of pituitary gland]",, -Attribute,Anatomical,System of,System of,"Spatial qualifier describing location, morphology, or extent. Example: [^{Subject}] -> [Hepatic artery anatomy]",, -Association,Therapeutic,Targeted therapy of,Targeted therapy of (HemOnc),Links therapeutic interventions to clinical indications or contraindications. Example: [Voxelotor] -> [Hydroxyurea and Voxelotor],,https://hemonc.org/wiki/HemOncKB_canonical_triples -Attribute,Procedural,Technique of,Technique of (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Personal Wellbeing Score rating scale] -> [Personal Wellbeing Score rating - I was happy yesterday]",Specify the systematic method of an observation.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/observable-entity/observable-entity-defining-attributes#technique -Attribute,Temporal,Temp related to,Temporally related to (SNOMED),"Qualifies clinical events by their timing, sequence, or duration. Example: [Orthostatic proteinuria] -> [Orthostatic body position]", Applies to perioperative complications and clinical findings where there is no causal relationship but a time-relative association exists.,https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/clinical-finding-and-disorder/clinical-finding-defining-attributes#temporally-related-to -Attribute,Temporal,Time aspect of,Time aspect of,"Qualifies clinical events by their timing, sequence, or duration. Example: [100D] -> [Did the patient have major surgery during the 100 days prior to admission [CMS Assessment]]",, -Hierarchy,Categorical – up,Topic of,Topic of,Assigns a concept to a high-level grouping or classification chapter.,, -Attribute,Anatomical,Topography of ICDO,Topography of ICDO,"Spatial qualifier describing location, morphology, or extent. Example: [Overlapping lesion of connective, subcutaneous and other soft tissues] -> [Chordoma, NOS, of overlapping lesion of connective, subcutaneous and other soft tissues]",, -Attribute,Context,Towards,Towards (SNOMED),Specific situational modifier or contextual role. Example: [Antimicrobial susceptibility of organism by matrix assisted laser desorption ionization time of flight mass spectrometry] -> [Anti-infective agent],"Specify a disposition, what the disposition is towards, i.e. a specific triggering agent, or more generally, participant in the realization of the disposition.",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/observable-entity/observable-entity-defining-attributes#towards -Attribute,Pharmaceutical,Transformation of,Dose form transformation of (SNOMED),"A pharmaceutical dose-form attribute, matching the existing Dose form of/Has dose form family. Example: [Disperse] -> [Granules for gastro-resistant oral suspension]",, -Association,Administrative,Typ bill3 - Typ bill,Typ bill 3 digits to Typ bill Full (UB04),"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Identity,Version lineage,UCUM replaced by,Concept replaced by (UCUM),Handles lifecycle transitions and deprecated concept history.,,https://github.com/OHDSI/Vocabulary-v5.0/wiki/Vocab.-LOINC#relationships -Attribute,Procedural,Unit of admin of,Unit of administration of (SNOMED),"Defines execution technicalities like route of administration, method, or device used.",, -Attribute,Procedural,Using device,Using device (SNOMED),"Defines execution technicalities like route of administration, method, or device used. Example: [Primary open reduction of fracture of short bone and fixation using screw] -> [Bone screw]","Using device (attribute) refers to the instrument or equipment utilized to execute an action. It is used when the device is actually used to carry out the action, that is the focus of the procedure. ",https://docs.snomed.org/education/snomed-ct-education-editorial-guide/readme/authoring/domain-specific-modeling/procedure/procedure-defining-attributes/index#using-device -Attribute,Temporal,Variable has date,Variable has date (NAACCR),"Qualifies clinical events by their timing, sequence, or duration.",, -Composition,Logical,Variable to Schema,Variable to Schema (NAACCR),"Conceptual grouping/aggregation of entities (e.g., Q&A, HOI, SMQ, Schema). Example: [Regional Nodes Positive] -> [Anterior 2/3 of Tongue, Tip, Border, and Tongue, NOS (excluding Malignant Melanoma)]",, -Association,Diagnostic,Variant of,Variant refer to concept,"Links evidence, tests, variants, or clinical findings to support diagnostic reasoning. Example: [BCR::ABL1 gene fusion measurement] -> [B lymphoblastic leukemia lymphoma with t(9:22) (q34;q11.2); BCR-ABL 1]",, -Composition,Logical,Version contains,OMOP CDM Version contains table/field (OMOP),"Conceptual grouping/aggregation of entities (e.g., Q&A, HOI, SMQ, Schema). Example: [OMOP CDM Version 5.4.2] -> [condition_era.condition_concept_id]",, -Association,Administrative,Visit cat - PoS,Visit category to Place of Service (OMOP),"Operational link for system billing, metadata, dictionaries, or encounter tracking.",, -Attribute,Pharmaceutical,VMP has prescr stat,VMP has prescribing status (SNOMED),Qualifies physical drug delivery properties or dose forms.,, -Attribute,Pharmaceutical,VMP of,Virtual Medicinal Product of (SNOMED),Qualifies physical drug delivery properties or dose forms.,, -Attribute,Pharmaceutical,VMP prescr stat of,VMP prescribing status of (SNOMED),Qualifies physical drug delivery properties or dose forms.,, -Attribute,Pharmaceutical,VRP has prescr stat,VRP has prescribing status (SNOMED),Qualifies physical drug delivery properties or dose forms.,, -Attribute,Pharmaceutical,VRP of,VRP of (SNOMED),Qualifies physical drug delivery properties or dose forms.,, -Attribute,Pharmaceutical,VRP prescr stat of,VRP prescribing status of (SNOMED),Qualifies physical drug delivery properties or dose forms.,, diff --git a/src/groundworkers/plugins.py b/src/groundworkers/plugins.py index f4783d6..bba8765 100644 --- a/src/groundworkers/plugins.py +++ b/src/groundworkers/plugins.py @@ -282,17 +282,13 @@ def concept_shared_standard_ancestor( offset: int = 0, ) -> dict[str, Any]: """Find a nearest common standard ancestor within optional constraints.""" - constraints = {} - if concept_class_ids is not None: - constraints["concept_class_ids"] = concept_class_ids - if vocabulary_ids is not None: - constraints["vocabulary_ids"] = vocabulary_ids - if limit != 10: - constraints["limit"] = limit - if offset != 0: - constraints["offset"] = offset return self._require_mapping_service().concept_shared_standard_ancestor( - concept_ids, max_depth=max_depth, **constraints + concept_ids, + max_depth=max_depth, + concept_class_ids=concept_class_ids, + vocabulary_ids=vocabulary_ids, + limit=limit, + offset=offset, ) def concept_relationship_neighborhood( diff --git a/src/groundworkers/services/graph.py b/src/groundworkers/services/graph.py index 4ee178e..0b65abb 100644 --- a/src/groundworkers/services/graph.py +++ b/src/groundworkers/services/graph.py @@ -46,7 +46,7 @@ def _short_text(value: str, *, limit: int = 120) -> str: compact = " ".join(value.split()) if len(compact) <= limit: return compact - return f"{compact[:limit - 3]}..." + return f"{compact[: limit - 3]}..." @dataclass(frozen=True) @@ -78,7 +78,9 @@ class GraphService: _ASSOCIATION_KIND = "Association" _HIERARCHY_KIND = "Hierarchy" - def __init__(self, adapter: OmopGraphAdapter, cdm: CDMAdapter | None = None) -> None: + def __init__( + self, adapter: OmopGraphAdapter, cdm: CDMAdapter | None = None + ) -> None: self._adapter = adapter # Optional: only required by the classified-edge traversals below # (concept_associations / concept_extended_inheritance). @@ -91,16 +93,22 @@ def __init__(self, adapter: OmopGraphAdapter, cdm: CDMAdapter | None = None) -> def get_concept(self, concept_id: int) -> dict[str, Any] | None: return self._adapter.get_concept(concept_id) - def get_concept_by_code(self, vocabulary_id: str, code: str) -> list[dict[str, Any]]: + def get_concept_by_code( + self, vocabulary_id: str, code: str + ) -> list[dict[str, Any]]: return self._adapter.get_concept_by_code(vocabulary_id, code) - def concept_views(self, concept_ids: Sequence[int]) -> dict[int, dict[str, Any]]: + def concept_views( + self, concept_ids: Sequence[int], *, strict: bool = False + ) -> dict[int, dict[str, Any]]: """Batch-fetch normalized concept views keyed by concept_id. Thin passthrough to the adapter's batch lookup. Returns an empty dict for unknown ids or on backend failure (the adapter swallows enrichment errors), so callers can treat a missing key as "unknown concept". """ + if strict: + return self._adapter.concept_views(concept_ids, strict=True) return self._adapter.concept_views(concept_ids) def canonicalize_domain(self, domain: str | None) -> str | None: @@ -176,7 +184,9 @@ def get_descendants(self, concept_id: int, max_depth: int) -> list[dict[str, Any queue: deque[tuple[int, int]] = deque( (child_id, 1) for child_id in self._adapter.children(concept_id) ) - return self._walk_hierarchy(queue=queue, neighbour_getter=self._adapter.children, max_depth=max_depth) + return self._walk_hierarchy( + queue=queue, neighbour_getter=self._adapter.children, max_depth=max_depth + ) def _walk_hierarchy( self, @@ -212,15 +222,22 @@ def _walk_hierarchy( # Edges # ------------------------------------------------------------------ + def get_relationship_edges( + self, concept_id: int, relationship_ids: list[str], limit: int + ) -> list[dict[str, Any]]: + return self._adapter.relationship_edges(concept_id, relationship_ids, limit) + def get_edges(self, concept_id: int) -> dict[str, Any]: if self._adapter.get_concept(concept_id) is None: raise GroundworkersError("NOT_FOUND", f"Concept {concept_id} was not found") outbound = self._adapter.edges(concept_id, direction="out", active_only=False) inbound = self._adapter.edges(concept_id, direction="in", active_only=False) - other_ids = tuple(dict.fromkeys( - [e["object_id"] for e in outbound] + [e["subject_id"] for e in inbound] - )) + other_ids = tuple( + dict.fromkeys( + [e["object_id"] for e in outbound] + [e["subject_id"] for e in inbound] + ) + ) views = self._adapter.concept_views(other_ids) return { @@ -312,15 +329,30 @@ def _classified_edges( if self._adapter.get_concept(concept_id) is None: raise GroundworkersError("NOT_FOUND", f"Concept {concept_id} was not found") - result: dict[str, Any] = {"concept_id": concept_id, "predicate_kind": predicate_kind} + result: dict[str, Any] = { + "concept_id": concept_id, + "predicate_kind": predicate_kind, + } with self._cdm.session() as session: if direction in ("out", "both"): result["outbound"] = self._query_classified_edges( - session, concept_id, predicate_kind, predicate_subkinds, active_only, limit, outbound=True + session, + concept_id, + predicate_kind, + predicate_subkinds, + active_only, + limit, + outbound=True, ) if direction in ("in", "both"): result["inbound"] = self._query_classified_edges( - session, concept_id, predicate_kind, predicate_subkinds, active_only, limit, outbound=False + session, + concept_id, + predicate_kind, + predicate_subkinds, + active_only, + limit, + outbound=False, ) return result @@ -446,7 +478,9 @@ def find_path( } for step in steps ] - serialised.append({"length": len(serialised_steps), "steps": serialised_steps}) + serialised.append( + {"length": len(serialised_steps), "steps": serialised_steps} + ) return {"found": True, "paths": serialised} def find_equivalency_path( @@ -456,7 +490,11 @@ def find_equivalency_path( max_depth: int, allow_hierarchical_traversal: bool = False, ) -> dict[str, Any]: - kinds = self._IDENTITY_AND_HIERARCHY_KINDS if allow_hierarchical_traversal else self._IDENTITY_KINDS + kinds = ( + self._IDENTITY_AND_HIERARCHY_KINDS + if allow_hierarchical_traversal + else self._IDENTITY_KINDS + ) return self.find_path( source_id=source_id, target_id=target_id, @@ -472,7 +510,9 @@ def find_equivalency_path( def map_to_standard(self, vocabulary_id: str, code: str) -> dict[str, Any]: source_list = self._adapter.get_concept_by_code(vocabulary_id, code) if not source_list: - raise GroundworkersError("NOT_FOUND", f"Concept {vocabulary_id}:{code} was not found") + raise GroundworkersError( + "NOT_FOUND", f"Concept {vocabulary_id}:{code} was not found" + ) source = source_list[0] if source["standard_concept"]: @@ -520,14 +560,16 @@ def get_neighbors( for nid in data["neighbor_ids"]: view = views.get(nid) if view: - neighbors.append({ - "concept_id": view["concept_id"], - "concept_name": view["concept_name"], - "vocabulary_id": view["vocabulary_id"], - "domain_id": view["domain_id"], - "concept_class_id": view["concept_class_id"], - "standard_concept": view["standard_concept"], - }) + neighbors.append( + { + "concept_id": view["concept_id"], + "concept_name": view["concept_name"], + "vocabulary_id": view["vocabulary_id"], + "domain_id": view["domain_id"], + "concept_class_id": view["concept_class_id"], + "standard_concept": view["standard_concept"], + } + ) terminated_reason = data["terminated_reason"] return { @@ -551,7 +593,9 @@ def ground_with_plan(self, request: GroundingPlan) -> dict[str, Any]: logger.info( "concept_ground plan query=%r parent_ids=%s tiers=%s", _short_text(request.query), - list(request.constraints.parent_ids) if request.constraints.parent_ids is not None else None, + list(request.constraints.parent_ids) + if request.constraints.parent_ids is not None + else None, ["+".join(type(r).__name__ for r in tier) for tier in request.tiers], ) @@ -560,10 +604,15 @@ def ground_with_plan(self, request: GroundingPlan) -> dict[str, Any]: for tier in request.tiers: tier_started = time.perf_counter() tier_name = "+".join(type(r).__name__ for r in tier) - is_fts_tier = any(isinstance(r, (FullTextResolver, FullTextSynonymResolver)) for r in tier) + is_fts_tier = any( + isinstance(r, (FullTextResolver, FullTextSynonymResolver)) for r in tier + ) try: hits = self._adapter.run_ground_tier( - tier, request.query, constraints=request.constraints, limit=request.limit + tier, + request.query, + constraints=request.constraints, + limit=request.limit, ) except EmbeddingTierUnavailable as exc: # Skip this tier only. The remaining lexical tiers are still useful, @@ -571,7 +620,9 @@ def ground_with_plan(self, request: GroundingPlan) -> dict[str, Any]: embedding_tier_detail = exc.message logger.warning( "concept_ground tier skipped query=%r tier=%s detail=%s", - _short_text(request.query), tier_name, exc.message, + _short_text(request.query), + tier_name, + exc.message, ) continue # Drop FTS hits where fewer than min_fulltext_overlap of the query tokens @@ -579,15 +630,20 @@ def ground_with_plan(self, request: GroundingPlan) -> dict[str, Any]: if hits and is_fts_tier and request.min_fulltext_overlap > 0.0: query_tokens = set(request.query.lower().split()) results = [ - h for h in hits - if self._fts_overlap(query_tokens, h["matched_label"] or "") >= request.min_fulltext_overlap + h + for h in hits + if self._fts_overlap(query_tokens, h["matched_label"] or "") + >= request.min_fulltext_overlap ] else: results = list(hits) logger.info( "concept_ground tier done query=%r tier=%s duration_ms=%.1f raw=%d kept=%d", - _short_text(request.query), tier_name, - (time.perf_counter() - tier_started) * 1000.0, len(hits), len(results), + _short_text(request.query), + tier_name, + (time.perf_counter() - tier_started) * 1000.0, + len(hits), + len(results), ) if results: break @@ -613,7 +669,9 @@ def ground_with_plan(self, request: GroundingPlan) -> dict[str, Any]: "concept_ground complete query=%r duration_ms=%.1f matched_tier=%r used_embedding=%s result_count=%d", _short_text(request.query), (time.perf_counter() - overall_started) * 1000.0, - matched_tier, used_embedding, len(payload["results"]), + matched_tier, + used_embedding, + len(payload["results"]), ) return payload @@ -636,8 +694,7 @@ async def async_ground_with_plan(self, request: GroundingPlan) -> dict[str, Any] tier_started = time.perf_counter() tier_name = "+".join(type(r).__name__ for r in tier) is_fts_tier = any( - isinstance(r, (FullTextResolver, FullTextSynonymResolver)) - for r in tier + isinstance(r, (FullTextResolver, FullTextSynonymResolver)) for r in tier ) try: hits = await self._adapter.async_run_ground_tier( diff --git a/src/groundworkers/services/mapping.py b/src/groundworkers/services/mapping.py index c1ab47b..a2dc404 100644 --- a/src/groundworkers/services/mapping.py +++ b/src/groundworkers/services/mapping.py @@ -18,6 +18,7 @@ ) from groundworkers.services.graph import GraphService from groundworkers.services.grounding import ConceptGroundingService +from groundworkers.services.relationship_neighborhood import RelationshipTraversal from groundworkers.services.vocab import ( VocabService, normalize_text_for_matching, @@ -28,6 +29,8 @@ MAX_CANDIDATES_PER_CHANNEL = 20 MAX_CANDIDATE_UNION = 100 +# Allow multiple relationship types per node while bounding each seed's edge payload. +RELATIONSHIP_EDGES_PER_NODE = 5 _SYNC_EMBEDDING = object() _SYNC_GROUNDING = object() @@ -504,7 +507,9 @@ def concept_nearest_standard_ancestor( } selection_reason = "direct_concept_input" - decision = resolve_nearest_target(self._graph, seed_concept, max_depth, selection_reason) + decision = resolve_nearest_target( + self._graph, seed_concept, max_depth, selection_reason + ) path = ancestor_path_evidence(self._graph, seed_concept, decision, max_depth) warnings = decision.warnings + path.pop("warnings") return { @@ -513,12 +518,19 @@ def concept_nearest_standard_ancestor( "found": decision.selected is not None, "status": decision.status, "nearest_candidate_count": decision.nearest_count, - "seed_selection_policy": "rank_1_grounded_candidate" if query is not None else "explicit_concept_id", + "seed_selection_policy": "rank_1_grounded_candidate" + if query is not None + else "explicit_concept_id", "seed_candidates": results if query is not None else [seed], - "ancestry_source": "concept_ancestor", "distance_metric": "min_levels_of_separation", - "max_depth": max_depth, "evidence_route": decision.evidence_route, - "selected_parent": decision.selected, "selection_reason": decision.reason, - "alternative_parents": decision.alternatives, "warnings": warnings, **path, + "ancestry_source": "concept_ancestor", + "distance_metric": "min_levels_of_separation", + "max_depth": max_depth, + "evidence_route": decision.evidence_route, + "selected_parent": decision.selected, + "selection_reason": decision.reason, + "alternative_parents": decision.alternatives, + "warnings": warnings, + **path, } def concept_shared_standard_ancestor( @@ -554,72 +566,54 @@ def concept_relationship_neighborhood( max_depth: int = 3, max_nodes: int = 200, ) -> dict[str, Any]: - """Return bounded active paths over an explicit set of relationships.""" + """Return reached nodes and edges over bounded active relationship traversals.""" if self._graph is None: raise GroundworkersError( "BACKEND_UNAVAIL", "omop_graph backend is not configured" ) seeds = list(dict.fromkeys(concept_ids)) - if not seeds or any(concept_id <= 0 for concept_id in seeds): - raise ValueError("concept_ids must contain positive concept IDs") + if ( + not seeds + or len(seeds) > 50 + or any( + type(concept_id) is not int or concept_id <= 0 for concept_id in seeds + ) + ): + raise ValueError("concept_ids must contain 1..50 positive concept IDs") relationships = {value.strip() for value in relationship_ids if value.strip()} if not relationships: raise ValueError("relationship_ids must contain non-empty values") safe_depth = max(1, min(max_depth, 3)) safe_nodes = max(10, min(max_nodes, 500)) - edge_cache: dict[int, dict[str, Any]] = {} - reached_by_seed: dict[int, dict[int, int]] = {} - traversed_edges: dict[tuple[int, int, str], dict[str, Any]] = {} - truncated_seeds: list[int] = [] - - for seed in seeds: - depths = {seed: 0} - frontier = {seed} - for depth in range(1, safe_depth + 1): - next_frontier: set[int] = set() - for current_id in frontier: - if current_id not in edge_cache: - edge_cache[current_id] = self._graph.get_edges(current_id) - groups = edge_cache[current_id] - for edge in groups.get("outbound", []): - relationship_id = edge.get("relationship_id") - if not edge.get("valid") or relationship_id not in relationships: - continue - neighbor_id = edge.get("target_concept_id") - if not isinstance(neighbor_id, int) or neighbor_id <= 0: - continue - traversed_edges[(current_id, neighbor_id, relationship_id)] = { - "subject_id": current_id, - "object_id": neighbor_id, - "relationship_id": relationship_id, - } - if neighbor_id in depths: - continue - if len(depths) >= safe_nodes: - if seed not in truncated_seeds: - truncated_seeds.append(seed) - continue - depths[neighbor_id] = depth - next_frontier.add(neighbor_id) - frontier = next_frontier - if not frontier: - break - reached_by_seed[seed] = depths + traversal = RelationshipTraversal( + self._graph, + sorted(relationships), + safe_depth, + safe_nodes, + safe_nodes * RELATIONSHIP_EDGES_PER_NODE, + ) + walks = {seed: traversal.walk(seed) for seed in seeds} + reached_by_seed = {seed: walk.depths for seed, walk in walks.items()} + edges = { + key: edge for walk in walks.values() for key, edge in walk.edges.items() + } all_ids = tuple( - dict.fromkeys( - concept_id - for depths in reached_by_seed.values() - for concept_id in depths - ) + sorted({cid for depths in reached_by_seed.values() for cid in depths}) ) - views = self._graph.concept_views(all_ids) - nodes = [ - views[concept_id] - for concept_id in all_ids - if concept_id in views - ] + views = self._graph.concept_views(all_ids, strict=True) + missing = set(all_ids) - views.keys() + missing_seeds = sorted(missing.intersection(seeds)) + if missing_seeds: + raise GroundworkersError( + "NOT_FOUND", f"Unknown seed concept IDs: {missing_seeds}" + ) + if missing: + raise GroundworkersError( + "QUERY_ERROR", "Relationship traversal metadata is incomplete" + ) + nodes = [views[concept_id] for concept_id in all_ids] return { "seed_concept_ids": seeds, "reached_by_seed": { @@ -632,10 +626,14 @@ def concept_relationship_neighborhood( for seed, depths in reached_by_seed.items() }, "nodes": nodes, - "edges": list(traversed_edges.values()), - "truncated_seeds": truncated_seeds, + "edges": list(edges.values()), + "truncated_seeds": sorted( + seed for seed, walk in walks.items() if walk.truncated + ), "max_depth": safe_depth, "max_nodes_per_seed": safe_nodes, + "max_edges_per_seed": traversal.max_edges, + "returned_edge_count": len(edges), } async def async_concept_nearest_standard_ancestor( diff --git a/src/groundworkers/services/relationship_neighborhood.py b/src/groundworkers/services/relationship_neighborhood.py new file mode 100644 index 0000000..2acc03b --- /dev/null +++ b/src/groundworkers/services/relationship_neighborhood.py @@ -0,0 +1,79 @@ +"""Per-seed bounded traversal over explicit active vocabulary relationships.""" + +from dataclasses import dataclass, field +from typing import Any + +from groundworkers.services.graph import GraphService + + +@dataclass +class SeedWalk: + depths: dict[int, int] + edges: dict[tuple[int, int, str], dict[str, Any]] = field(default_factory=dict) + truncated: bool = False + + +@dataclass +class RelationshipTraversal: + graph: GraphService + relationship_ids: list[str] + max_depth: int + max_nodes: int + max_edges: int + edge_cache: dict[int, list[dict[str, Any]]] = field(default_factory=dict) + + def walk(self, seed: int) -> SeedWalk: + result = SeedWalk(depths={seed: 0}) + frontier = {seed} + for depth in range(1, self.max_depth + 1): + next_frontier: set[int] = set() + for current_id in sorted(frontier): + next_frontier.update(self._expand(result, current_id, depth)) + frontier = next_frontier + if not frontier: + break + return result + + def _expand(self, result: SeedWalk, current_id: int, depth: int) -> set[int]: + if len(result.edges) >= self.max_edges: + if not result.truncated: + rows = self.edge_cache.get(current_id) + if rows is None: + # Another seed may need the full list, so do not cache a probe. + rows = self.graph.get_relationship_edges( + current_id, self.relationship_ids, 1 + ) + result.truncated = bool(rows) + return set() + if current_id not in self.edge_cache: + self.edge_cache[current_id] = self.graph.get_relationship_edges( + current_id, self.relationship_ids, self.max_edges + 1 + ) + rows = self.edge_cache[current_id] + if len(rows) > self.max_edges: + result.truncated = True + next_frontier: set[int] = set() + for edge in rows[: self.max_edges]: + neighbor = edge["target_concept_id"] + key = (current_id, neighbor, edge["relationship_id"]) + if self._admit(result, key, depth): + next_frontier.add(neighbor) + return next_frontier + + def _admit(self, result: SeedWalk, key: tuple[int, int, str], depth: int) -> bool: + current, neighbor, relationship = key + if key not in result.edges and len(result.edges) >= self.max_edges: + result.truncated = True + return False + discovered = neighbor not in result.depths + if discovered: + if len(result.depths) >= self.max_nodes: + result.truncated = True + return False + result.depths[neighbor] = depth + result.edges[key] = { + "subject_id": current, + "object_id": neighbor, + "relationship_id": relationship, + } + return discovered diff --git a/src/groundworkers/services/vocab.py b/src/groundworkers/services/vocab.py index d963819..377530f 100644 --- a/src/groundworkers/services/vocab.py +++ b/src/groundworkers/services/vocab.py @@ -25,9 +25,7 @@ from groundworkers.base.errors import GroundworkersError from groundworkers.base.sql import ( NormalizationProfile, - normalization_expression, - normalization_key_expression, - normalized_key, + normalization_match_expression, ) logger = logging.getLogger(__name__) @@ -150,6 +148,10 @@ def _detect_fts_sidecars(self) -> None: if self._fts_name_sidecar is not None: return engine = self._cdm.vocab_engine + if engine.dialect.name == "duckdb": + self._fts_name_sidecar = False + self._fts_synonym_sidecar = False + return schema = physical_schema_of(engine, schema_tag=Role.VOCAB) try: inspector = sa_inspect(engine) @@ -190,6 +192,8 @@ def probe_fulltext(self) -> tuple[bool, str | None]: reporting instead of treating schema presence as operational readiness. """ + if self._cdm.vocab_engine.dialect.name == "duckdb": + return False, "PostgreSQL full-text search is unavailable on DuckDB." try: with self._cdm.session() as session: probe_label = session.execute( @@ -399,18 +403,18 @@ def search_normalized_with_plan( include_synonyms=include_synonyms, allow_unindexed_scan=allow_unindexed_scan, ) - key = normalized_key(normalized_query) results: list[ConceptMatch] = [] seen_ids: set[int] = set() try: - name_expr = normalization_expression( + name_predicate = normalization_match_expression( Concept.concept_name, + normalized_query, profile=normalization_profile, remove_stop_phrases=remove_stop_phrases, + dialect_name=self._cdm.vocab_engine.dialect.name, ) - name_key_expr = normalization_key_expression(Concept.concept_name) with self._cdm.session() as session: if ( @@ -440,7 +444,7 @@ def search_normalized_with_plan( "classification_concept" ), Concept.is_valid_expr().label("is_active"), - ).where(name_key_expr == key, name_expr == normalized_query), + ).where(name_predicate), domain=domain, vocabulary_id=vocabulary_id, standard_only=standard_only, @@ -455,13 +459,12 @@ def search_normalized_with_plan( if include_synonyms: remaining = limit - len(results) if remaining > 0: - syn_expr = normalization_expression( + syn_predicate = normalization_match_expression( Concept_Synonym.concept_synonym_name, + normalized_query, profile=normalization_profile, remove_stop_phrases=remove_stop_phrases, - ) - syn_key_expr = normalization_key_expression( - Concept_Synonym.concept_synonym_name + dialect_name=self._cdm.vocab_engine.dialect.name, ) syn_stmt = self._apply_concept_filters( select( @@ -482,7 +485,7 @@ def search_normalized_with_plan( Concept_Synonym, Concept_Synonym.concept_id == Concept.concept_id, ) - .where(syn_key_expr == key, syn_expr == normalized_query), + .where(syn_predicate), domain=domain, vocabulary_id=vocabulary_id, standard_only=standard_only, @@ -542,6 +545,13 @@ def _normalized_search_plan( return "scoped_scan" if allow_unindexed_scan: return "unindexed_scan" + if engine.dialect.name == "duckdb": + raise GroundworkersError( + "INVALID_INPUT", + "Normalized search on DuckDB needs a domain, vocabulary_id, or " + "parent_ids scope. Set allow_unindexed_scan=True only to explicitly " + "allow a full vocabulary scan.", + ) raise GroundworkersError( "INVALID_INPUT", "Normalized search needs an index or a scope. Build indexes via Setup → " diff --git a/src/groundworkers/tui/presenters/embeddings.py b/src/groundworkers/tui/presenters/embeddings.py index 738a81b..7c66bc5 100644 --- a/src/groundworkers/tui/presenters/embeddings.py +++ b/src/groundworkers/tui/presenters/embeddings.py @@ -324,11 +324,11 @@ def _setup_rows_with_coverage( def _store_cells(configuration: EmbeddingConfiguration) -> tuple[str, str, str]: - if configuration.backend == "sqlitevec": + if configuration.backend in {"sqlitevec", "duckdbvss"}: return ( "Store", ( - f"{configuration.vector_store_name} · sqlitevec · " + f"{configuration.vector_store_name} · {configuration.backend} · " f"{configuration.database_path or configuration.database_name}" ), _path_status(configuration.database_path_exists), @@ -355,7 +355,7 @@ def _embedding_status( database_ready: bool, reconciliation: ModelReconciliation | None = None, ) -> SemanticStatus: - if configuration.backend not in {"pgvector", "sqlitevec"}: + if configuration.backend not in {"pgvector", "sqlitevec", "duckdbvss"}: return SemanticStatus.ERROR if not configuration.embeddings_supported: return SemanticStatus.ERROR @@ -378,8 +378,8 @@ def _embedding_message( database_ready: bool, reconciliation: ModelReconciliation | None = None, ) -> str: - if configuration.backend not in {"sqlitevec", "pgvector"}: - return "Choose a supported vector store backend: sqlitevec or pgvector. FAISS can be configured separately as a query cache." + if configuration.backend not in {"sqlitevec", "pgvector", "duckdbvss"}: + return "Choose a supported vector store backend: sqlitevec, pgvector, or duckdbvss. FAISS can be configured separately as a query cache." if not configuration.embeddings_supported: return "The selected model is not declared as embedding-capable. Choose an embedding model before population." if reconciliation is None: @@ -404,11 +404,7 @@ def _reconciliation_message(reconciliation: ModelReconciliation) -> str: for severity in (DiagnosticSeverity.ERROR, DiagnosticSeverity.WARNING): blocking = next( - ( - item - for item in reconciliation.diagnostics - if item.severity is severity - ), + (item for item in reconciliation.diagnostics if item.severity is severity), None, ) if blocking is not None: @@ -487,9 +483,7 @@ def _path_status(exists: bool | None) -> str: def _provider_label(configuration: EmbeddingConfiguration) -> str: endpoint = configuration.api_base or "provider default" - return ( - f"{configuration.provider_name} ({configuration.provider_kind}) · {endpoint}" - ) + return f"{configuration.provider_name} ({configuration.provider_kind}) · {endpoint}" def _model_label(configuration: EmbeddingConfiguration) -> str: @@ -560,7 +554,11 @@ def _coverage_status( return reconciled if not report.coverage.available: return SemanticStatus.ERROR - return SemanticStatus.OK if _embedding_setup_ready(capability) else SemanticStatus.WARNING + return ( + SemanticStatus.OK + if _embedding_setup_ready(capability) + else SemanticStatus.WARNING + ) def _coverage_message( diff --git a/src/groundworkers/tui/wizards/embedding_store.py b/src/groundworkers/tui/wizards/embedding_store.py index 41445d1..2987158 100644 --- a/src/groundworkers/tui/wizards/embedding_store.py +++ b/src/groundworkers/tui/wizards/embedding_store.py @@ -45,7 +45,11 @@ def back(self) -> WizardSnapshot: def apply(self) -> WizardResult: result = initialize_embedding_store(self._session.configuration) if not result.reachable: - detail = result.failure.detail if result.failure is not None else None + detail = ( + f"{result.failure.detail} {result.failure.next_action}" + if result.failure is not None + else None + ) return WizardResult( status=WizardResultStatus.FAILED, summary="The embedding store could not be initialized.", @@ -78,7 +82,7 @@ def _snapshot(self) -> WizardSnapshot: "Creates the omop-emb registry schema and backend prerequisites.", ), warnings=( - "This is the only setup action that performs embedding-store DDL.", + "Initialization writes registry and storage metadata. For DuckDB, close services and other readers using the embedding file first.", ), ready_to_apply=True, ), diff --git a/src/groundworkers/tui/wizards/embeddings.py b/src/groundworkers/tui/wizards/embeddings.py index a388628..ed88549 100644 --- a/src/groundworkers/tui/wizards/embeddings.py +++ b/src/groundworkers/tui/wizards/embeddings.py @@ -424,6 +424,13 @@ def _run_step(self) -> FormStep: def _review_step(self) -> ReviewStep: command = self._command() if self._coverage is not None else None warnings = [] + if ( + self._coverage is not None + and self._coverage.configuration.backend == "duckdbvss" + ): + warnings.append( + "Close services and other readers using the DuckDB CDM and embedding files before population." + ) if self._coverage is None: warnings.append("Embedding coverage is unavailable.") else: diff --git a/src/groundworkers/tui/wizards/graph_maintenance.py b/src/groundworkers/tui/wizards/graph_maintenance.py index 731ba73..d5d557b 100644 --- a/src/groundworkers/tui/wizards/graph_maintenance.py +++ b/src/groundworkers/tui/wizards/graph_maintenance.py @@ -27,9 +27,10 @@ GraphRemediation, GraphRemediationRequest, build_graph_remediation_commands, + cdm_is_duckdb, outstanding_remediations, - packaged_predicate_csv_dir, start_graph_remediation_run, + validate_graph_preparation_paths, ) from groundworkers.application.setup.models import ConnectionResult from groundworkers.tui.routes import SETUP_ROUTE @@ -46,7 +47,7 @@ GraphRemediation.FUNCTIONAL_INDEXES: "Create functional text indexes", } -_SOURCE_BUNDLED = "bundled" +_SOURCE_UPSTREAM = "upstream" _SOURCE_CUSTOM = "custom" @@ -78,13 +79,14 @@ def __init__( self._readiness = readiness self._outstanding = outstanding_remediations(readiness) self._selected: set[GraphRemediation] = set(self._outstanding) - # Pre-filled from the copy bundled with Groundworkers while omop-graph's - # own CSVs stay outside its wheel. Still a prompt rather than a silent - # default: a site may maintain its own classification. - self._bundled_csv_dir: Path | None = packaged_predicate_csv_dir() - self._csv_dir: Path | None = self._bundled_csv_dir - self._use_bundled = self._bundled_csv_dir is not None - self._cluster = True + self._csv_dir: Path | None = None + self._use_upstream = True + self._duckdb = cdm_is_duckdb(session.configuration.stack) + if self._duckdb: + self._selected.intersection_update( + {GraphRemediation.RELATIONSHIP_CLASSIFICATION} + ) + self._cluster = not self._duckdb self._step_index = 0 validate_wizard_steps(self._steps()) @@ -109,6 +111,7 @@ def review(self) -> WizardTransition: def apply(self) -> WizardResult: try: + validate_graph_preparation_paths(self._session.configuration.stack) commands = build_graph_remediation_commands( self._request(), config_path=self._session.configuration.path ) @@ -160,7 +163,7 @@ def _steps(self) -> tuple[FormStep | ReviewStep, ...]: steps: list[FormStep | ReviewStep] = [self._actions_step()] if GraphRemediation.RELATIONSHIP_CLASSIFICATION in self._selected: steps.append(self._sources_step()) - if not self._use_bundled: + if not self._use_upstream: steps.append(self._custom_sources_step()) steps.append(self._review_step()) return tuple(steps) @@ -172,8 +175,15 @@ def _actions_step(self) -> FormStep: _LABELS[remediation], kind=FieldKind.BOOLEAN, default=remediation in self._selected, + disabled=( + self._duckdb + and remediation is not GraphRemediation.RELATIONSHIP_CLASSIFICATION + ), help=( - "Reported missing by the readiness check." + "This index maintenance requires PostgreSQL." + if self._duckdb + and remediation is not GraphRemediation.RELATIONSHIP_CLASSIFICATION + else "Reported missing by the readiness check." if remediation in self._outstanding else "Already present. Selecting this re-runs it." ), @@ -186,6 +196,7 @@ def _actions_step(self) -> FormStep: "CLUSTER tables after indexing", kind=FieldKind.BOOLEAN, default=self._cluster, + disabled=self._duckdb, help="Rewrites the heap. Needs free disk space on large vocabularies.", ) ) @@ -197,28 +208,14 @@ def _actions_step(self) -> FormStep: ) def _sources_step(self) -> FormStep: - """Ask which classification to load, not where it lives. - - The bundled copy is the answer in almost every case, and its absolute - path is far too long to read in a single-line field. Only an operator - who keeps their own classification needs to type anything. - """ - if self._bundled_csv_dir is None: - choices = (ChoiceOption(_SOURCE_CUSTOM, "Choose a directory"),) - default = _SOURCE_CUSTOM - help_text = ( - "No classification is bundled with this install, so a directory " - "is required." - ) - else: - choices = ( - ChoiceOption( - _SOURCE_BUNDLED, "Use the classification bundled with Groundworkers" - ), - ChoiceOption(_SOURCE_CUSTOM, "Use a different directory"), - ) - default = _SOURCE_BUNDLED if self._use_bundled else _SOURCE_CUSTOM - help_text = f"Bundled copy: {self._bundled_csv_dir}" + """Offer the upstream defaults or an explicit custom classification.""" + choices = ( + ChoiceOption( + _SOURCE_UPSTREAM, "Use the classification shipped with omop-graph" + ), + ChoiceOption(_SOURCE_CUSTOM, "Use a different directory"), + ) + default = _SOURCE_UPSTREAM if self._use_upstream else _SOURCE_CUSTOM return FormStep( key="sources", title="Predicate classification", @@ -230,8 +227,7 @@ def _sources_step(self) -> FormStep: kind=FieldKind.CHOICE, default=default, choices=choices, - disabled=self._bundled_csv_dir is None, - help=help_text, + help="omop-graph includes its default classification; no directory is needed.", ), ), ) @@ -263,6 +259,10 @@ def _review_step(self) -> ReviewStep: if remediation in self._selected ) warnings: list[str] = [] + if self._duckdb: + warnings.append( + "Close all services and other readers using the DuckDB CDM file before starting preparation." + ) if not self._selected: warnings.append("Nothing is selected, so nothing will run.") if self._cluster and GraphRemediation.FUNCTIONAL_INDEXES in self._selected: @@ -293,18 +293,25 @@ def _request(self) -> GraphRemediationRequest: actions=tuple(r for r in GraphRemediation if r in self._selected), predicate_csv_dir=self._csv_dir, cluster=self._cluster, + duckdb=self._duckdb, ) def _submit_form( self, step: FormStep, values: Mapping[str, object] ) -> tuple[ValidationIssue, ...]: if step.key == "actions": + if self._duckdb and any( + _as_bool(values.get(key)) + for remediation, key in _FIELD_BY_REMEDIATION.items() + if remediation is not GraphRemediation.RELATIONSHIP_CLASSIFICATION + ): + return (ValidationIssue("Index preparation requires PostgreSQL."),) for remediation, key in _FIELD_BY_REMEDIATION.items(): if _as_bool(values.get(key)): self._selected.add(remediation) else: self._selected.discard(remediation) - self._cluster = _as_bool(values.get("cluster")) + self._cluster = not self._duckdb and _as_bool(values.get("cluster")) if not self._selected: return ( ValidationIssue( @@ -317,9 +324,9 @@ def _submit_form( return () if step.key == "sources": choice = str(values.get("predicate_source") or "").strip() - self._use_bundled = choice == _SOURCE_BUNDLED - if self._use_bundled: - self._csv_dir = self._bundled_csv_dir + self._use_upstream = choice == _SOURCE_UPSTREAM + if self._use_upstream: + self._csv_dir = None return () if step.key == "custom_sources": raw = str(values.get("predicate_csv_dir") or "").strip() @@ -377,7 +384,7 @@ def _safe_values(self, step: FormStep | ReviewStep) -> Mapping[str, object]: if step.key == "sources": return { "predicate_source": ( - _SOURCE_BUNDLED if self._use_bundled else _SOURCE_CUSTOM + _SOURCE_UPSTREAM if self._use_upstream else _SOURCE_CUSTOM ) } if step.key == "custom_sources": diff --git a/tests/integration/test_omop_duckdb_runtime.py b/tests/integration/test_omop_duckdb_runtime.py new file mode 100644 index 0000000..5f3504b --- /dev/null +++ b/tests/integration/test_omop_duckdb_runtime.py @@ -0,0 +1,834 @@ +"""Real file-backed DuckDB serving contracts; no external model provider is used.""" + +from __future__ import annotations + +import subprocess +import sys +from datetime import date +from pathlib import Path +from unittest.mock import Mock + +import numpy as np +import pytest +import sqlalchemy as sa +from oa_configurator import ( + CDMDatabaseConfig, + ConfigurationError, + ConnectionConfig, + GenericDatabaseConfig, + ModelConfig, + ProviderConfig, + Resolver, + StackConfig, + VectorStoreConfig, + save_stack_config, +) +from omop_alchemy.cdm.model.vocabulary import ( + Concept, + Concept_Ancestor, + Concept_Class, + Concept_Relationship, + Concept_Synonym, + Domain, + Relationship, + Vocabulary, +) +from omop_emb.backends import open_vector_store_writer +from omop_emb.backends.embedding_table import ConceptEmbeddingRecord +from omop_emb.backends.index_config import FlatIndexConfig, HNSWIndexConfig +from omop_emb.config import MetricType +from omop_graph.cli import relationship_classification + +from groundworkers.app import build_application +from groundworkers.application.setup.configuration import load_configuration +from groundworkers.application.setup.databases import ( + resolve_database_targets, + verify_database_target, +) +from groundworkers.bootstrap import ( + DatabaseUnavailableError, + build_app_config_from_stack, +) + +MODEL_NAME = "groundworkers-duckdb:v1" + + +def _seed_vocabulary(resolved): + engine = resolved.create_engines()[0] + metadata = sa.MetaData() + models = ( + Domain, + Vocabulary, + Concept_Class, + Concept, + Relationship, + Concept_Synonym, + Concept_Ancestor, + Concept_Relationship, + ) + # Clone DDL metadata: ALTER-capable dialects must not mutate the shared + # cyclic ORM constraints used by the SQLite tests in the same process. + tables = { + model.__tablename__: model.__table__.to_metadata(metadata) for model in models + } + tables["concept"].append_column(sa.Column("concept_name_tsvector", sa.String())) + try: + with engine.begin() as connection: + metadata.create_all(connection) + connection.execute( + tables["domain"].insert(), + [ + { + "domain_id": "Condition", + "domain_name": "Condition", + "domain_concept_id": 1, + } + ], + ) + connection.execute( + tables["vocabulary"].insert(), + [ + { + "vocabulary_id": "SNOMED", + "vocabulary_name": "SNOMED", + "vocabulary_concept_id": 1, + } + ], + ) + connection.execute( + tables["concept_class"].insert(), + [ + { + "concept_class_id": "Clinical Finding", + "concept_class_name": "Clinical Finding", + "concept_class_concept_id": 1, + } + ], + ) + connection.execute( + tables["concept"].insert(), + [ + { + "concept_id": cid, + "concept_name": label, + "domain_id": "Condition", + "vocabulary_id": "SNOMED", + "concept_class_id": "Clinical Finding", + "standard_concept": "S", + "concept_code": str(cid), + "valid_start_date": date(2020, 1, 1), + "valid_end_date": date(2099, 12, 31), + } + for cid, label in ( + (1, "Metadata"), + (11, "Diabetes mellitus"), + (12, "Metabolic disorder"), + (13, "Other condition"), + ) + ], + ) + connection.execute( + tables["relationship"].insert(), + [ + { + "relationship_id": rid, + "relationship_name": rid, + "is_hierarchical": "1", + "defines_ancestry": "1", + "reverse_relationship_id": reverse, + "relationship_concept_id": 1, + } + for rid, reverse in ( + ("Is a", "Subsumes"), + ("Subsumes", "Is a"), + ("Box of", "Has box"), + ("Has box", "Box of"), + ) + ], + ) + connection.execute( + tables["concept_synonym"].insert(), + [ + { + "concept_id": 11, + "concept_synonym_name": "Sugar disease", + "language_concept_id": 1, + } + ], + ) + connection.execute( + tables["concept_ancestor"].insert(), + [ + { + "ancestor_concept_id": ancestor, + "descendant_concept_id": descendant, + "min_levels_of_separation": depth, + "max_levels_of_separation": depth, + } + for ancestor, descendant, depth in ( + (11, 11, 0), + (12, 12, 0), + (12, 11, 1), + ) + ], + ) + connection.execute( + tables["concept_relationship"].insert(), + [ + { + "concept_id_1": source, + "concept_id_2": target, + "relationship_id": rid, + "valid_start_date": date(2020, 1, 1), + "valid_end_date": date(2099, 12, 31), + } + for source, target, rid in ( + (11, 12, "Is a"), + (12, 11, "Subsumes"), + (11, 13, "Box of"), + ) + ], + ) + relationship_classification(resolved=resolved) + relationship_classification(resolved=resolved) + finally: + engine.dispose() + + +@pytest.fixture(scope="module") +def duckdb_stack(tmp_path_factory): + root = tmp_path_factory.mktemp("duckdb-runtime") + stack = StackConfig( + connections={ + "cdm": ConnectionConfig( + dialect="duckdb", database_name=str(root / "cdm.duckdb") + ), + "emb": ConnectionConfig( + dialect="duckdb", database_name=str(root / "emb.duckdb") + ), + }, + databases={ + "cdm": CDMDatabaseConfig(connection="cdm"), + "emb": GenericDatabaseConfig(connection="emb"), + }, + providers={ + "offline": ProviderConfig(provider="ollama", base_url="http://127.0.0.1:1") + }, + models={ + "fixture": ModelConfig( + provider="offline", + model=MODEL_NAME, + embeddings=True, + embedding_dim=3, + query_prefix="query: ", + ) + }, + vector_stores={ + "vectors": VectorStoreConfig(backend_type="duckdbvss", database="emb") + }, + tools={ + "groundworkers": { + "cdm_db": "cdm", + "embedding_model_name": "fixture", + "vector_store_name": "vectors", + } + }, + ) + resolver = Resolver(stack) + _seed_vocabulary(resolver.resolve_database("cdm")) + writer = open_vector_store_writer(resolver.resolve_vector_store("vectors")) + index = HNSWIndexConfig(metric_type=MetricType.COSINE) + try: + writer.register_model( + model_name=MODEL_NAME, + provider_type="ollama", + index_config=FlatIndexConfig(metric_type=MetricType.COSINE), + dimensions=3, + ) + writer.upsert_embeddings( + model_name=MODEL_NAME, + records=tuple( + ConceptEmbeddingRecord(cid, "Condition", "SNOMED", True, True) + for cid in (11, 12, 13) + ), + embeddings=np.asarray( + [[1, 0, 0], [0.9, 0.1, 0], [0, 1, 0]], dtype=np.float32 + ), + ) + writer.rebuild_index(model_name=MODEL_NAME, index_config=index) + assert writer.physical_indexes(MODEL_NAME) + finally: + writer.close() + return stack + + +@pytest.fixture +def duckdb_app(duckdb_stack): + app = build_application(build_app_config_from_stack(duckdb_stack)) + try: + yield app + finally: + if app.adapters.omop_emb is not None: + app.adapters.omop_emb.close() + app.config.cdm_engine.dispose() + + +def test_duckdb_serves_lexical_graph_and_named_relationship_queries(duckdb_app): + services = duckdb_app.services + assert ( + services.vocab.search_exact("Diabetes mellitus")[0].concept["concept_id"] == 11 + ) + assert ( + services.vocab.search_normalized_with_plan( + "DIABETES MELLITUS", vocabulary_id="SNOMED" + )[0][0].concept["concept_id"] + == 11 + ) + assert services.vocab.fts_available is False + assert services.vocab.search_fulltext("diabetes") == ([], False) + assert "DuckDB" in services.vocab.probe_fulltext()[1] + assert 12 in {row["concept_id"] for row in services.graph.get_ancestors(11, 2)} + neighborhood = services.mapping.concept_relationship_neighborhood( + [11], relationship_ids=["Box of"], max_depth=1 + ) + assert neighborhood["returned_edge_count"] == 1 + assert neighborhood["edges"][0]["object_id"] == 13 + + +def test_duckdb_embedding_reader_serves_stored_and_encoded_queries( + duckdb_app, monkeypatch +): + class FixedEncoder: + model = MODEL_NAME + provider = "ollama" + + def embed_texts(self, texts, *, role, batch_size=None): + return [[1.0, 0.0, 0.0] for _ in texts] + + monkeypatch.setattr( + "omop_llm.build_model_backend_from_resolved", lambda _: FixedEncoder() + ) + adapter = duckdb_app.adapters.omop_emb + assert adapter.index_status()["available"] is True + assert ( + adapter.get_neighbours(11, limit=2, model_name=None)["results"][0]["concept_id"] + == 12 + ) + result = adapter.search( + "a test query", + limit=2, + domain="Condition", + vocabulary="SNOMED", + standard_only=True, + active_only=True, + model_name=None, + ) + assert result["results"][0]["concept_id"] == 11 + assert duckdb_app.config.embedding_model.query_prefix == "query: " + with pytest.raises(sa.exc.DBAPIError, match=r"[Rr]ead.only"): + with duckdb_app.config.cdm_engine.begin() as connection: + connection.execute(sa.text("CREATE TABLE must_not_exist (value INT)")) + with pytest.raises(sa.exc.DBAPIError, match=r"[Rr]ead.only"): + with adapter._get_backend().emb_engine.begin() as connection: + connection.execute(sa.text("CREATE TABLE must_not_exist (value INT)")) + + +def test_duckdb_readiness_coexists_with_runtime_and_second_process( + duckdb_app, tmp_path +): + path = tmp_path / "runtime.toml" + save_stack_config(duckdb_app.config.stack, path) + snapshot = load_configuration(config_path=path) + results = [ + verify_database_target(target) for target in resolve_database_targets(snapshot) + ] + assert all(result.connected for result in results), results + assert not any(result.failure for result in results) + child = subprocess.run( + [ + sys.executable, + "-c", + "import sys; import sqlalchemy as sa; from groundworkers.bootstrap import build_app_config; " + "c=build_app_config(config_path=sys.argv[1]); " + "conn=c.cdm_engine.connect(); print(conn.scalar(sa.text('SELECT count(*) FROM concept'))); " + "conn.close(); c.cdm_engine.dispose()", + str(path), + ], + capture_output=True, + text=True, + timeout=30, + check=True, + ) + assert child.stdout.strip() == "4" + + +def test_duckdb_missing_cdm_does_not_create_a_file(duckdb_stack, tmp_path): + stack = duckdb_stack.model_copy(deep=True) + path = tmp_path / "missing.duckdb" + stack.connections["cdm"].database_name = str(path) + with pytest.raises(DatabaseUnavailableError): + build_app_config_from_stack(stack) + assert not path.exists() + + +@pytest.mark.parametrize("alias", ["absolute", "relative", "tilde", "symlink", "hardlink"]) +def test_duckdb_rejects_colocated_embedding_file( + duckdb_stack, tmp_path, monkeypatch, alias +): + stack = duckdb_stack.model_copy(deep=True) + path = Path(stack.connections["cdm"].database_name) + monkeypatch.chdir(path.parent) + monkeypatch.setenv("HOME", str(path.parent)) + if alias in {"symlink", "hardlink"}: + linked = tmp_path / "alias.duckdb" + if alias == "hardlink": + linked.hardlink_to(path) + else: + linked.symlink_to(path) + database_name = str(linked) + else: + database_name = { + "absolute": str(path), + "relative": "./" + path.name, + "tilde": "~/" + path.name, + }[alias] + stack.connections["emb"].database_name = database_name + message = ( + "must be absolute" + if alias in {"tilde", "relative"} + else "separate file" + ) + with pytest.raises(ConfigurationError, match=message): + build_app_config_from_stack(stack) + + +@pytest.mark.parametrize("topology", ["memory", "separate_vocab", "mixed_dialect"]) +def test_duckdb_rejects_unsupported_cdm_topologies(duckdb_stack, tmp_path, topology): + stack = duckdb_stack.model_copy(deep=True) + if topology == "memory": + stack.connections["cdm"].database_name = ":memory:" + message = "must be absolute" + else: + stack.connections["other"] = ConnectionConfig( + dialect="sqlite" if topology == "mixed_dialect" else "duckdb", + database_name=str(tmp_path / "other.db"), + ) + stack.databases["cdm"].vocab_connection = "other" + message = "primary and vocabulary roles in one file" + exception = ConfigurationError if topology == "memory" else DatabaseUnavailableError + with pytest.raises(exception, match=message): + build_app_config_from_stack(stack) + + +@pytest.fixture +def preparation_config(duckdb_stack, tmp_path): + stack = duckdb_stack.model_copy(deep=True) + stack.connections["cdm"].database_name = str(tmp_path / "cdm.duckdb") + stack.connections["emb"].database_name = str(tmp_path / "new-emb.duckdb") + stack.tools["omop_graph"] = {"cdm_db": "cdm"} + stack.tools["omop_emb"] = { + "cdm_db": "cdm", + "embedding_model_name": "fixture", + "vector_store_name": "vectors", + } + _seed_vocabulary(Resolver(stack).resolve_database("cdm")) + path = tmp_path / "prepare.toml" + save_stack_config(stack, path) + return path + + +def test_duckdb_explicit_initialization_releases_writer_for_inspection( + preparation_config, +): + from groundworkers.application.setup.embedding_population import ( + load_embedding_coverage_report, + ) + from groundworkers.application.setup.embedding_setup import ( + initialize_embedding_store, + ) + from groundworkers.application.setup.models import EmbeddingStoreState + + snapshot = load_configuration(config_path=preparation_config) + assert not Path(snapshot.stack.connections["emb"].database_name).exists() + result = initialize_embedding_store(snapshot) + assert result.reachable, result.failure + assert result.state is EmbeddingStoreState.EMPTY + target = next( + target + for target in resolve_database_targets(snapshot) + if target.key == "database.embedding" + ) + assert verify_database_target(target).connected + coverage = load_embedding_coverage_report(snapshot) + assert coverage.coverage.available + assert coverage.coverage.pending_total == 4 + # Readers from both inspections have closed; another writer can acquire the file. + assert initialize_embedding_store(snapshot).reachable + + +def test_duckdb_initialization_refuses_active_reader_then_succeeds(preparation_config): + from omop_emb.backends import open_vector_store_reader + + from groundworkers.application.setup.embedding_setup import ( + initialize_embedding_store, + ) + + snapshot = load_configuration(config_path=preparation_config) + assert initialize_embedding_store(snapshot).reachable + store = Resolver(snapshot.stack).resolve_vector_store("vectors") + reader = open_vector_store_reader(store) + try: + assert reader.get_registered_models() == () + result = initialize_embedding_store(snapshot) + assert not result.reachable + assert "conflicting" in result.failure.detail + assert "close other readers" in result.failure.next_action + assert reader.get_registered_models() == () + finally: + reader.close() + assert initialize_embedding_store(snapshot).reachable + + +@pytest.mark.parametrize("alias", ["same_connection", "symlink", "hardlink"]) +def test_duckdb_initialization_rejects_colocated_store_without_opening_writer( + duckdb_stack, tmp_path, alias +): + from groundworkers.application.setup.embedding_setup import ( + initialize_embedding_store, + ) + + stack = duckdb_stack.model_copy(deep=True) + cdm_path = Path(stack.connections["cdm"].database_name) + before = cdm_path.read_bytes() + if alias == "same_connection": + stack.databases["emb"].connection = "cdm" + else: + linked = tmp_path / "alias.duckdb" + if alias == "hardlink": + linked.hardlink_to(cdm_path) + else: + linked.symlink_to(cdm_path) + stack.connections["emb"].database_name = str(linked) + path = tmp_path / "colocated.toml" + save_stack_config(stack, path) + initializer = Mock(side_effect=AssertionError("must not open a writer")) + result = initialize_embedding_store( + load_configuration(config_path=path), initializer=initializer + ) + assert not result.reachable + initializer.assert_not_called() + assert cdm_path.read_bytes() == before + + +def test_duckdb_setup_can_launch_classification_and_population( + preparation_config, monkeypatch +): + import os + from types import SimpleNamespace + + from groundworkers.application.setup.embedding_population import ( + build_embedding_population_command, + ) + from groundworkers.application.setup.embedding_setup import ( + load_embedding_configuration, + ) + from groundworkers.application.setup.models import EmbeddingPopulationRequest + from groundworkers.tui.state import SetupSession + from groundworkers.tui.wizards.graph_maintenance import ( + GraphMaintenanceWizardController, + ) + + commands = [] + + def launch(plan, **kwargs): + commands.extend(plan) + return SimpleNamespace( + run_id="test", total=len(plan), root=preparation_config.parent + ) + + monkeypatch.setattr( + "groundworkers.tui.wizards.graph_maintenance.start_graph_remediation_run", + launch, + ) + controller = GraphMaintenanceWizardController( + SetupSession(config_path=preparation_config), None + ) + fields = {field.key: field for field in controller.start().step.fields} + assert not fields["load_relationship_classification"].disabled + assert fields["create_fulltext_indexes"].disabled + assert fields["create_functional_indexes"].disabled + assert fields["cluster"].disabled + assert controller.submit({"create_fulltext_indexes": True}).issues + assert not controller.submit({"load_relationship_classification": True}).issues + assert controller.review().snapshot.can_apply + assert controller.apply().status.value == "applied" + assert len(commands) == 1 + result = subprocess.run( + [str(Path(sys.executable).with_name("omop-graph")), *commands[0].argv[1:]], + env={**os.environ, **dict(commands[0].environment)}, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr + configuration = load_embedding_configuration( + load_configuration(config_path=preparation_config) + ) + request = EmbeddingPopulationRequest( + standard_only=True, + vocabulary_mode="all", + vocabularies=(), + limit=1, + batch_size=10, + ) + command = build_embedding_population_command( + configuration, request, config_path=preparation_config + ) + assert command.argv[1:] == ( + "embeddings", + "add-embeddings", + "--model-name", + "fixture", + "--batch-size", + "10", + "--standard-only", + "--num-embeddings", + "1", + ) + assert dict(command.environment)["OA_CONFIG_PATH"] == str(preparation_config) + # Replace only the external encoder. CLI parsing, registration, CDM reads + # and DuckDB writes all use the installed upstream implementation. + script = """ +import omop_emb.interface as interface +from omop_emb.cli.cli_app import app +class FixedEncoder: + provider = "ollama" + model = "groundworkers-duckdb:v1" + def dimensions(self): + return 3 + def embed_texts(self, texts, *, role, batch_size=None): + return [[1.0, 0.0, 0.0] for text in texts] +interface.build_model_backend_from_resolved = lambda resolved: FixedEncoder() +app() +""" + result = subprocess.run( + [sys.executable, "-c", script, *command.argv[1:]], + env={**os.environ, **dict(command.environment)}, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr + from groundworkers.application.setup.embedding_population import ( + load_embedding_coverage_report, + ) + + report = load_embedding_coverage_report( + load_configuration(config_path=preparation_config) + ) + assert report.coverage.available + assert report.coverage.embedded_total == 1 + assert report.coverage.pending_total == 3 + + +@pytest.mark.parametrize( + "target", ["missing", "relative", "directory", "memory", "tilde"] +) +def test_duckdb_missing_embedding_file_is_reported_without_creation( + duckdb_stack, tmp_path, monkeypatch, target +): + from groundworkers.application.setup.embedding_population import ( + load_embedding_coverage_report, + ) + + stack = duckdb_stack.model_copy(deep=True) + path = tmp_path / "missing-emb.duckdb" + if target == "directory": + path.mkdir() + monkeypatch.chdir(tmp_path) + stack.connections["emb"].database_name = ( + ":memory:" + if target == "memory" + else path.name + if target == "relative" + else str(path) + ) + if target == "tilde": + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / "existing-emb.duckdb").touch() + stack.connections["emb"].database_name = "~/existing-emb.duckdb" + path = tmp_path / "~" / "existing-emb.duckdb" + construct_engines = Mock( + side_effect=AssertionError("validation must precede engines") + ) + monkeypatch.setattr( + "groundworkers.bootstrap.create_runtime_cdm_engines", construct_engines + ) + with pytest.raises(ConfigurationError) as raised: + build_app_config_from_stack(stack) + assert stack.connections["emb"].database_name in str(raised.value) + construct_engines.assert_not_called() + if target != "directory": + assert not path.exists() + + config_path = tmp_path / "runtime.toml" + save_stack_config(stack, config_path) + report = load_embedding_coverage_report(load_configuration(config_path=config_path)) + assert report is not None + assert report.coverage.available is False + assert str(raised.value) in report.coverage.blocker + + +@pytest.mark.parametrize("profile", ["verbatim", "aggressive", "drug_name"]) +@pytest.mark.parametrize("remove_stop_phrases", [True, False]) +def test_duckdb_native_normalization_matches_python( + duckdb_app, profile, remove_stop_phrases +): + from groundworkers.base.sql import normalization_expression + from groundworkers.services.vocab import normalize_text_for_matching + + labels = ( + "Type 2 diabetes mellitus NOS", + "sodium chloride 0.9% w/ sterile water", + "tablet_W/O coating", + "drug extended release", + "drug modified release", + " UNKNOWN ", + "___", + "w/o", + "W/O", + "wo", + "tabs\tand\tspaces", + "", + ) + with duckdb_app.config.cdm_engine.connect() as connection: + for label in labels: + expected, _ = normalize_text_for_matching( + label, profile=profile, remove_stop_phrases=remove_stop_phrases + ) + actual = connection.scalar( + sa.select( + normalization_expression( + sa.literal(label), + profile=profile, + remove_stop_phrases=remove_stop_phrases, + dialect_name="duckdb", + ) + ) + ) + assert actual == expected, (label, profile, remove_stop_phrases) + + +@pytest.mark.parametrize("failure", [None, "build", "verify"]) +def test_plugin_readiness_releases_host_files_before_preparation( + preparation_config, failure +): + from types import SimpleNamespace + + from groundworkers.app import load_plugin_readiness + from groundworkers.application.setup.embedding_setup import ( + initialize_embedding_store, + ) + from groundworkers.plugins import PluginReadinessResult, PluginReadinessState + + snapshot = load_configuration(config_path=preparation_config) + assert initialize_embedding_store(snapshot).reachable + + def build(context, _config): + with context.cdm_engine.connect() as connection: + assert connection.scalar(sa.text("SELECT count(*) FROM concept")) == 4 + context.embedding_backend_factory().get_registered_models() + if failure == "build": + raise RuntimeError("fixture build failure") + return object() + + def verify(_state): + if failure == "verify": + raise RuntimeError("fixture verification failure") + return PluginReadinessResult(state=PluginReadinessState.READY, summary="Ready") + + plugin = SimpleNamespace( + name="preparation_probe", config_cls=None, build=build, verify_readiness=verify + ) + result = load_plugin_readiness(str(preparation_config), plugin) + expected = ( + PluginReadinessState.UNCONFIGURED + if failure == "build" + else PluginReadinessState.ERROR + if failure == "verify" + else PluginReadinessState.READY + ) + assert result.state is expected + # The plugin used the shared factory directly. Both it and the pooled CDM + # connection must be released, on success and on either failure path. + assert initialize_embedding_store(snapshot).reachable + engine = Resolver(snapshot.stack).resolve_database("cdm").create_engines()[0] + try: + with engine.begin() as connection: + connection.execute( + sa.text("CREATE TABLE preparation_probe (value INTEGER)") + ) + finally: + engine.dispose() + + +@pytest.mark.parametrize("role", ["cdm", "emb"]) +@pytest.mark.parametrize("path_form", ["relative", "tilde"]) +def test_preparation_rejects_nonabsolute_paths_before_opening_writer( + preparation_config, monkeypatch, role, path_form +): + from oa_configurator import load_stack_config + + from groundworkers.application.setup.embedding_setup import ( + initialize_embedding_store, + ) + from groundworkers.tui.state import SetupSession + from groundworkers.tui.wizards.graph_maintenance import ( + GraphMaintenanceWizardController, + ) + + stack = load_stack_config(preparation_config) + absolute = Path(stack.connections[role].database_name) + monkeypatch.chdir(absolute.parent) + monkeypatch.setenv("HOME", str(absolute.parent)) + stack.connections[role].database_name = ( + "~/" if path_form == "tilde" else "" + ) + absolute.name + save_stack_config(stack, preparation_config) + initializer = Mock(side_effect=AssertionError("must not open a writer")) + result = initialize_embedding_store( + load_configuration(config_path=preparation_config), initializer=initializer + ) + assert not result.reachable + initializer.assert_not_called() + if role == "cdm": + launch = Mock(side_effect=AssertionError("must not launch a command")) + monkeypatch.setattr( + "groundworkers.tui.wizards.graph_maintenance.start_graph_remediation_run", + launch, + ) + controller = GraphMaintenanceWizardController( + SetupSession(config_path=preparation_config), None + ) + controller.submit({"load_relationship_classification": True}) + result = controller.apply() + assert result.status.value == "failed" + assert "absolute" in result.detail + launch.assert_not_called() + + +@pytest.mark.parametrize("path_form", ["relative", "tilde"]) +def test_duckdb_serving_requires_absolute_cdm_paths_without_embeddings( + duckdb_stack, monkeypatch, path_form +): + stack = duckdb_stack.model_copy(deep=True) + cdm = Path(stack.connections["cdm"].database_name) + monkeypatch.chdir(cdm.parent) + monkeypatch.setenv("HOME", str(cdm.parent)) + stack.tools["groundworkers"].pop("vector_store_name") + stack.tools["groundworkers"].pop("embedding_model_name") + stack.connections["cdm"].database_name = ( + "~/" if path_form == "tilde" else "" + ) + cdm.name + with pytest.raises(DatabaseUnavailableError, match="must be absolute"): + build_app_config_from_stack(stack) diff --git a/tests/support/wheel_smoke.py b/tests/support/wheel_smoke.py index 8084da6..b0307ff 100644 --- a/tests/support/wheel_smoke.py +++ b/tests/support/wheel_smoke.py @@ -30,6 +30,68 @@ def main() -> None: ) if mode == "extras": _exercise_transports() + elif mode == "duckdb": + _exercise_duckdb() + elif mode == "base": + from oa_configurator import CDMDatabaseConfig, ConnectionConfig, StackConfig + + from groundworkers.bootstrap import ( + DatabaseUnavailableError, + build_app_config_from_stack, + ) + + stack = StackConfig( + connections={ + "file": ConnectionConfig( + dialect="duckdb", + database_name=str(Path("missing.duckdb").resolve()), + ) + }, + databases={"cdm": CDMDatabaseConfig(connection="file")}, + tools={"groundworkers": {"cdm_db": "cdm"}}, + ) + try: + build_app_config_from_stack(stack) + except DatabaseUnavailableError as exc: + assert 'Install "groundworkers[duckdb]"' in str(exc) + else: + raise AssertionError("base wheel unexpectedly opened a DuckDB connection") + + +def _exercise_duckdb() -> None: + from importlib.resources import files + from tempfile import TemporaryDirectory + + import duckdb + from oa_configurator import CDMDatabaseConfig, ConnectionConfig, StackConfig + from sqlalchemy import text + from sqlalchemy.exc import DBAPIError + + from groundworkers.bootstrap import build_app_config_from_stack + + assert (files("omop_graph") / "data" / "predicate_mapping.csv").is_file() + with TemporaryDirectory() as directory: + path = Path(directory) / "vocabulary.duckdb" + with duckdb.connect(str(path)) as writer: + writer.execute("CREATE TABLE concept (concept_id INTEGER PRIMARY KEY)") + writer.execute("INSERT INTO concept VALUES (11)") + stack = StackConfig( + connections={"file": ConnectionConfig(dialect="duckdb", database_name=str(path))}, + databases={"cdm": CDMDatabaseConfig(connection="file")}, + tools={"groundworkers": {"cdm_db": "cdm"}}, + ) + config = build_app_config_from_stack(stack) + try: + with config.cdm_engine.connect() as connection: + assert connection.scalar(text("SELECT concept_id FROM concept")) == 11 + try: + connection.execute(text("CREATE TABLE should_fail (value INTEGER)")) + except DBAPIError as exc: + assert "read-only" in str(exc).lower() + else: + raise AssertionError("installed DuckDB runtime allowed writes") + finally: + config.cdm_engine.dispose() def _exercise_transports() -> None: diff --git a/tests/test_bootstrap_config.py b/tests/test_bootstrap_config.py index 382f938..b0dd093 100644 --- a/tests/test_bootstrap_config.py +++ b/tests/test_bootstrap_config.py @@ -216,7 +216,7 @@ def test_cdm_engine_initialization_error_includes_safe_cause(monkeypatch) -> Non def fail(*_args, **_kwargs): raise RuntimeError(f"schema registry rejected {unsafe_url}") - monkeypatch.setattr(bootstrap, "create_cdm_engines", fail) + monkeypatch.setattr(bootstrap, "create_runtime_cdm_engines", fail) with pytest.raises(ConfigurationError) as raised: build_app_config_from_stack(stack) @@ -268,7 +268,7 @@ def test_describe_masks_connection_and_provider_secrets(monkeypatch) -> None: import groundworkers.bootstrap as bootstrap primary = create_engine("sqlite:///:memory:") - monkeypatch.setattr(bootstrap, "create_cdm_engines", lambda *_args, **_kw: (primary, primary)) + monkeypatch.setattr(bootstrap, "create_runtime_cdm_engines", lambda *_args, **_kw: (primary, primary)) monkeypatch.setattr(bootstrap, "cdm_sessionmaker", lambda *_args, **_kw: lambda: None) config = build_app_config_from_stack(stack) rendered = repr(config.describe()) @@ -344,7 +344,7 @@ def test_describe_leaks_no_value_declared_sensitive_by_the_schema(monkeypatch) - import groundworkers.bootstrap as bootstrap primary = create_engine("sqlite:///:memory:") - monkeypatch.setattr(bootstrap, "create_cdm_engines", lambda *_args, **_kw: (primary, primary)) + monkeypatch.setattr(bootstrap, "create_runtime_cdm_engines", lambda *_args, **_kw: (primary, primary)) monkeypatch.setattr(bootstrap, "cdm_sessionmaker", lambda *_args, **_kw: lambda: None) rendered = build_app_config_from_stack(stack).describe() diff --git a/tests/unit/test_configuration_provider.py b/tests/unit/test_configuration_provider.py index 0fe218b..d42205e 100644 --- a/tests/unit/test_configuration_provider.py +++ b/tests/unit/test_configuration_provider.py @@ -74,7 +74,11 @@ def test_groundworkers_config_uses_real_1x_references() -> None: @pytest.mark.parametrize( ("dialect", "cdm_schema", "vocab_schema"), - [("sqlite", None, None), ("postgresql+psycopg", "clinical", "vocabulary")], + [ + ("sqlite", None, None), + ("duckdb", None, None), + ("postgresql+psycopg", "clinical", "vocabulary"), + ], ) def test_cdm_wizard_output_round_trips_through_stack_resolver( tmp_path: Path, dialect: str, cdm_schema: str | None, vocab_schema: str | None @@ -83,10 +87,12 @@ def test_cdm_wizard_output_round_trips_through_stack_resolver( sqlite_path = tmp_path / "cdm.db" sqlite_path.touch() service = GroundworkersConfigMutationService(path) - controller = ConfigWizardController(cdm_setup_workflow(MutationOperation.CREATE), service) + controller = ConfigWizardController( + cdm_setup_workflow(MutationOperation.CREATE), service + ) controller.start() controller.submit({"connection_name": "cdm_main", "dialect": dialect}) - if dialect != "sqlite": + if dialect not in {"sqlite", "duckdb"}: controller.submit( { "host": "postgres.example.test", @@ -110,10 +116,14 @@ def test_cdm_wizard_output_round_trips_through_stack_resolver( loaded = load_stack_config(path) resolved = Resolver(loaded).resolve_database("cdm_db") assert resolved.connection.dialect_name == ( - "sqlite" if dialect == "sqlite" else "postgresql" + dialect if dialect in {"sqlite", "duckdb"} else "postgresql" + ) + assert resolved.schema_for_role(Role.PRIMARY) == ( + "main" if dialect == "duckdb" else cdm_schema + ) + assert resolved.schema_for_role(Role.VOCAB) == ( + "main" if dialect == "duckdb" else vocab_schema ) - assert resolved.schema_for_role(Role.PRIMARY) == cdm_schema - assert resolved.schema_for_role(Role.VOCAB) == vocab_schema if dialect == "sqlite": saved = tomllib.loads(path.read_text(encoding="utf-8")) cdm_entry = saved["databases"]["cdm_db"] @@ -229,6 +239,37 @@ def test_provider_updates_an_existing_cdm_configuration(tmp_path: Path) -> None: assert reloaded.stack.databases["cdm_db"].cdm_schema is None +@pytest.mark.parametrize( + "schemas", [("clinical", "vocabulary", "results"), ("main", None, None)] +) +def test_updating_duckdb_preserves_schema_defaults(tmp_path: Path, schemas) -> None: + path = tmp_path / "config.toml" + file = tmp_path / "cdm.duckdb" + file.touch() + stack = _cdm_stack() + stack.connections["cdm_main"].dialect = "duckdb" + stack.connections["cdm_main"].database_name = str(file) + database = stack.databases["cdm_db"] + database.cdm_schema, database.vocab_schema, database.results_schema = schemas + save_stack_config(stack, path) + controller = ConfigWizardController( + cdm_setup_workflow(MutationOperation.UPDATE), + GroundworkersConfigMutationService(path), + ) + controller.start() + assert not controller.submit({}).issues + schemas_step = controller.submit({}).snapshot.step + defaults = {field.key: field.default for field in schemas_step.fields} + assert ( + tuple(defaults[key] for key in ("cdm_schema", "vocab_schema", "results_schema")) + == schemas + ) + assert not controller.submit({}).issues + assert controller.apply().status is WizardResultStatus.APPLIED + saved = load_stack_config(path).databases["cdm_db"] + assert (saved.cdm_schema, saved.vocab_schema, saved.results_schema) == schemas + + def test_model_discovery_refreshes_only_the_future_choice_and_keeps_secrets_safe( tmp_path: Path, caplog: pytest.LogCaptureFixture, @@ -312,8 +353,9 @@ def discover(provider: str, base_url: str | None, api_key: str | None): ((("stored-model", "other-model"), False), (("other-model",), True)), ) def test_model_update_preserves_stored_choice_and_flags_missing_inventory( - tmp_path: Path, discovered: tuple[str, ...], flagged: bool + tmp_path: Path, discovered: tuple[str, ...], flagged: bool, monkeypatch ) -> None: + monkeypatch.chdir(tmp_path) path = tmp_path / "config.toml" save_stack_config(build_embedding_stack(model_name="stored-model"), path) controller = ConfigWizardController( @@ -692,3 +734,23 @@ def _drive(convention: str): model = load_stack_config(path).models["embedding_model"] assert (model.document_prefix, model.query_prefix) == ("index: ", "ask: ") + + +@pytest.mark.parametrize("path_form", ["relative", "tilde"]) +def test_duckdb_cdm_setup_requires_an_absolute_path(tmp_path, monkeypatch, path_form): + file = tmp_path / "cdm.duckdb" + file.touch() + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + config = tmp_path / "config.toml" + controller = ConfigWizardController( + cdm_setup_workflow(MutationOperation.CREATE), + GroundworkersConfigMutationService(config), + ) + controller.start() + assert not controller.submit({"connection_name": "cdm", "dialect": "duckdb"}).issues + filename = "~/cdm.duckdb" if path_form == "tilde" else "cdm.duckdb" + result = controller.submit({"database_name": filename}) + assert [issue.field_key for issue in result.issues] == ["database_name"] + assert "absolute" in result.issues[0].message + assert not config.exists() diff --git a/tests/unit/test_graph_maintenance.py b/tests/unit/test_graph_maintenance.py index 22b16e4..68e8987 100644 --- a/tests/unit/test_graph_maintenance.py +++ b/tests/unit/test_graph_maintenance.py @@ -3,11 +3,13 @@ from pathlib import Path import pytest +from oa_configurator import StackConfig from groundworkers.application.setup.graph_maintenance import ( GraphRemediation, GraphRemediationRequest, build_graph_remediation_commands, + cdm_is_duckdb, outstanding_remediations, ) from groundworkers.application.setup.models import ( @@ -35,6 +37,14 @@ def _info(code: str) -> ResourceDiagnostic: return ResourceDiagnostic(code, "present", DiagnosticSeverity.INFO) +@pytest.mark.parametrize( + "stack", + [None, StackConfig(), StackConfig(tools={"groundworkers": {"cdm_db": "missing"}})], +) +def test_duckdb_detection_tolerates_incomplete_configuration(stack) -> None: + assert cdm_is_duckdb(stack) is False + + # --------------------------------------------------------------------------- # Deciding what is outstanding # --------------------------------------------------------------------------- @@ -55,9 +65,7 @@ def test_outstanding_is_read_from_the_readiness_diagnostics() -> None: def test_empty_relationship_tables_count_as_outstanding() -> None: """Tables present but empty is the same problem as absent, for grounding.""" - readiness = _readiness( - _info("graph_tables_present"), _warn("graph_tables_empty") - ) + readiness = _readiness(_info("graph_tables_present"), _warn("graph_tables_empty")) assert outstanding_remediations(readiness) == ( GraphRemediation.RELATIONSHIP_CLASSIFICATION, @@ -131,14 +139,12 @@ def test_relationship_classification_passes_the_csv_directory(tmp_path: Path) -> ) -def test_relationship_classification_without_a_directory_is_refused() -> None: - """omop-graph loads these from CSV and ships none, so there is no default.""" - with pytest.raises(ValueError, match=r"predicate_classification\.csv"): - build_graph_remediation_commands( - GraphRemediationRequest( - actions=(GraphRemediation.RELATIONSHIP_CLASSIFICATION,) - ) - ) +@pytest.mark.parametrize("duckdb", [False, True]) +def test_relationship_classification_uses_upstream_defaults_without_a_directory(duckdb) -> None: + commands = build_graph_remediation_commands( + GraphRemediationRequest(actions=(GraphRemediation.RELATIONSHIP_CLASSIFICATION,), duckdb=duckdb) + ) + assert commands[0].argv[1:] == ("relationship-classification",) def test_commands_are_ordered_tables_then_indexes(tmp_path: Path) -> None: @@ -170,25 +176,11 @@ def test_selecting_nothing_builds_nothing() -> None: assert build_graph_remediation_commands(GraphRemediationRequest(actions=())) == () -# --------------------------------------------------------------------------- -# Bundled predicate CSVs (temporary, pending the omop-graph packaging fix) -# --------------------------------------------------------------------------- - - -def test_the_bundled_predicate_csvs_are_installed() -> None: - """They are a stand-in for data omop-graph does not ship in its wheel. - - Guards the same failure this works around: files outside a package directory - vanish from the wheel silently, with nothing failing until runtime. - """ - from groundworkers.application.setup.graph_maintenance import ( - PREDICATE_CSV_NAMES, - packaged_predicate_csv_dir, - ) - - bundled = packaged_predicate_csv_dir() - - assert bundled is not None - for name in PREDICATE_CSV_NAMES: - assert (bundled / name).is_file() - assert (bundled / name).stat().st_size > 0 +@pytest.mark.parametrize( + "action", [GraphRemediation.FULLTEXT_INDEXES, GraphRemediation.FUNCTIONAL_INDEXES] +) +def test_duckdb_rejects_postgres_index_commands(action) -> None: + with pytest.raises(ValueError, match="requires PostgreSQL"): + build_graph_remediation_commands( + GraphRemediationRequest(actions=(action,), duckdb=True) + ) diff --git a/tests/unit/test_graph_maintenance_wizard.py b/tests/unit/test_graph_maintenance_wizard.py index ae3a962..afedae1 100644 --- a/tests/unit/test_graph_maintenance_wizard.py +++ b/tests/unit/test_graph_maintenance_wizard.py @@ -25,6 +25,13 @@ def _session(tmp_path: Path) -> SetupSession: return SetupSession(config_path=path) +def test_incomplete_configuration_does_not_crash_the_wizard(tmp_path: Path) -> None: + path = tmp_path / "incomplete.toml" + path.write_text('[tools.groundworkers]\ncdm_db = "missing"\n', encoding="utf-8") + controller = GraphMaintenanceWizardController(SetupSession(config_path=path), None) + assert controller.start().step.key == "actions" + + def _readiness(*codes: str) -> ConnectionResult: return ConnectionResult( target_key="database.graph", @@ -63,9 +70,7 @@ def test_selections_start_from_what_the_check_found(tmp_path: Path) -> None: assert values["create_functional_indexes"] is False -def test_running_only_indexes_needs_no_csv_step( - tmp_path: Path, monkeypatch -) -> None: +def test_running_only_indexes_needs_no_csv_step(tmp_path: Path, monkeypatch) -> None: launcher = _RecordingLauncher() monkeypatch.setattr( "groundworkers.tui.wizards.graph_maintenance.start_graph_remediation_run", @@ -135,9 +140,7 @@ def test_a_directory_with_the_csvs_is_accepted(tmp_path: Path, monkeypatch) -> N "groundworkers.tui.wizards.graph_maintenance.start_graph_remediation_run", launcher, ) - controller = GraphMaintenanceWizardController( - _session(tmp_path), _readiness() - ) + controller = GraphMaintenanceWizardController(_session(tmp_path), _readiness()) controller.submit( { "load_relationship_classification": True, @@ -197,12 +200,8 @@ def test_cancelling_changes_nothing(tmp_path: Path) -> None: assert result.status is WizardResultStatus.CANCELLED -def test_the_bundled_classification_is_offered_as_a_choice(tmp_path: Path) -> None: +def test_the_upstream_classification_is_offered_as_a_choice(tmp_path: Path) -> None: """Accepting it must not mean reading an 80-character path in a text field.""" - from groundworkers.application.setup.graph_maintenance import ( - packaged_predicate_csv_dir, - ) - controller = GraphMaintenanceWizardController(_session(tmp_path), _readiness()) snapshot = controller.submit( { @@ -215,11 +214,11 @@ def test_the_bundled_classification_is_offered_as_a_choice(tmp_path: Path) -> No assert snapshot.step.key == "sources" field = next(f for f in snapshot.step.fields if f.key == "predicate_source") - assert field.default == "bundled" - assert str(packaged_predicate_csv_dir()) in field.help + assert field.default == "upstream" + assert "omop-graph" in field.help -def test_accepting_the_bundled_classification_skips_the_path_step( +def test_accepting_the_upstream_classification_skips_the_path_step( tmp_path: Path, monkeypatch ) -> None: launcher = _RecordingLauncher() @@ -227,9 +226,7 @@ def test_accepting_the_bundled_classification_skips_the_path_step( "groundworkers.tui.wizards.graph_maintenance.start_graph_remediation_run", launcher, ) - controller = GraphMaintenanceWizardController( - _session(tmp_path), _readiness() - ) + controller = GraphMaintenanceWizardController(_session(tmp_path), _readiness()) controller.submit( { "load_relationship_classification": True, @@ -239,20 +236,14 @@ def test_accepting_the_bundled_classification_skips_the_path_step( } ) - review = controller.submit({"predicate_source": "bundled"}).snapshot + review = controller.submit({"predicate_source": "upstream"}).snapshot assert isinstance(review.step, ReviewStep) controller.apply() - from groundworkers.application.setup.graph_maintenance import ( - packaged_predicate_csv_dir, - ) + assert launcher.commands[0].argv[1:] == ("relationship-classification",) - assert str(packaged_predicate_csv_dir()) in launcher.commands[0].argv - -def test_the_bundled_default_can_be_overridden( - tmp_path: Path, monkeypatch -) -> None: +def test_the_upstream_default_can_be_overridden(tmp_path: Path, monkeypatch) -> None: """A site may maintain its own classification, so this stays a prompt.""" source = tmp_path / "site-predicates" source.mkdir() @@ -263,9 +254,7 @@ def test_the_bundled_default_can_be_overridden( "groundworkers.tui.wizards.graph_maintenance.start_graph_remediation_run", launcher, ) - controller = GraphMaintenanceWizardController( - _session(tmp_path), _readiness() - ) + controller = GraphMaintenanceWizardController(_session(tmp_path), _readiness()) controller.submit( { "load_relationship_classification": True, diff --git a/tests/unit/test_mapping_context.py b/tests/unit/test_mapping_context.py index 7f8c74f..5fe7479 100644 --- a/tests/unit/test_mapping_context.py +++ b/tests/unit/test_mapping_context.py @@ -44,11 +44,11 @@ def test_mapping_context_delegates_to_the_host_service(): } assert context.concept_shared_standard_ancestor([1, 2]) == { "concept_ids": [1, 2], - "kwargs": {"max_depth": 5}, + "kwargs": {"max_depth": 5, "concept_class_ids": None, "vocabulary_ids": None, "limit": 10, "offset": 0}, } assert context.concept_shared_standard_ancestor([1, 2], max_depth=8) == { "concept_ids": [1, 2], - "kwargs": {"max_depth": 8}, + "kwargs": {"max_depth": 8, "concept_class_ids": None, "vocabulary_ids": None, "limit": 10, "offset": 0}, } assert context.concept_relationship_neighborhood( [1, 2], relationship_ids=["Tradename of"], max_depth=2 @@ -74,6 +74,8 @@ def test_mapping_context_passes_shared_ancestor_constraints(): "max_depth": 0, "concept_class_ids": ["Clinical Drug"], "vocabulary_ids": ["RxNorm"], + "limit": 10, + "offset": 0, } diff --git a/tests/unit/test_mapping_service.py b/tests/unit/test_mapping_service.py index 9816a2c..a4c5f46 100644 --- a/tests/unit/test_mapping_service.py +++ b/tests/unit/test_mapping_service.py @@ -12,7 +12,18 @@ class StubVocabAdapter: - def search_exact(self, query: str, *, domain=None, vocabulary_id=None, standard_only=False, active_only=False, include_synonyms=True, parent_ids=None, limit=20): + def search_exact( + self, + query: str, + *, + domain=None, + vocabulary_id=None, + standard_only=False, + active_only=False, + include_synonyms=True, + parent_ids=None, + limit=20, + ): return [ ConceptMatch( concept={ @@ -30,7 +41,21 @@ def search_exact(self, query: str, *, domain=None, vocabulary_id=None, standard_ ) ] - def search_normalized(self, query: str, *, domain=None, vocabulary_id=None, standard_only=False, active_only=False, include_synonyms=False, normalization_profile="verbatim", parent_ids=None, remove_stop_phrases=True, limit=20, allow_unindexed_scan=False): + def search_normalized( + self, + query: str, + *, + domain=None, + vocabulary_id=None, + standard_only=False, + active_only=False, + include_synonyms=False, + normalization_profile="verbatim", + parent_ids=None, + remove_stop_phrases=True, + limit=20, + allow_unindexed_scan=False, + ): return [ ConceptMatch( concept={ @@ -63,23 +88,39 @@ def search_normalized_with_plan(self, query: str, **kwargs): "scoped_scan" if scoped else "unindexed_scan" ) - def search_fulltext(self, query: str, *, domain=None, vocabulary_id=None, standard_only=False, active_only=False, include_synonyms=True, parent_ids=None, min_rank=0.0, limit=20): - return ([ - ConceptMatch( - concept={ - "concept_id": 103, - "concept_name": "Diabetic disorder", - "concept_code": "44054008", - "vocabulary_id": "SNOMED", - "domain_id": "Condition", - "concept_class_id": "Clinical Finding", - "standard_concept": False, - "classification_concept": False, - "is_active": True, - }, - match_source="name", ts_rank=0.42, - ) - ], True) + def search_fulltext( + self, + query: str, + *, + domain=None, + vocabulary_id=None, + standard_only=False, + active_only=False, + include_synonyms=True, + parent_ids=None, + min_rank=0.0, + limit=20, + ): + return ( + [ + ConceptMatch( + concept={ + "concept_id": 103, + "concept_name": "Diabetic disorder", + "concept_code": "44054008", + "vocabulary_id": "SNOMED", + "domain_id": "Condition", + "concept_class_id": "Clinical Finding", + "standard_concept": False, + "classification_concept": False, + "is_active": True, + }, + match_source="name", + ts_rank=0.42, + ) + ], + True, + ) def navigate_to_standard(self, concept_ids: list[int]): return [ @@ -221,40 +262,88 @@ def get_concept_by_code(self, vocabulary_id: str, code: str): def get_ancestors(self, concept_id: int, max_depth: int): if concept_id == 102: return [ - {"concept_id": 301, "concept_name": "Diabetes mellitus", "vocabulary_id": "SNOMED", "domain_id": "Condition", "standard_concept": True, "depth": 1}, - {"concept_id": 302, "concept_name": "Endocrine disorder", "vocabulary_id": "SNOMED", "domain_id": "Condition", "standard_concept": True, "depth": 2}, + { + "concept_id": 301, + "concept_name": "Diabetes mellitus", + "vocabulary_id": "SNOMED", + "domain_id": "Condition", + "standard_concept": True, + "depth": 1, + }, + { + "concept_id": 302, + "concept_name": "Endocrine disorder", + "vocabulary_id": "SNOMED", + "domain_id": "Condition", + "standard_concept": True, + "depth": 2, + }, ] return [] def get_descendants(self, concept_id: int, max_depth: int): if concept_id == 301: return [ - {"concept_id": 401, "concept_name": "Child concept", "vocabulary_id": "SNOMED", "domain_id": "Condition", "standard_concept": True, "depth": 1} + { + "concept_id": 401, + "concept_name": "Child concept", + "vocabulary_id": "SNOMED", + "domain_id": "Condition", + "standard_concept": True, + "depth": 1, + } ] return [] def get_edges(self, concept_id: int): return { "outbound": [ - {"relationship_id": "Is a", "predicate_kind": "HIERARCHY", "target_concept_id": 301, "target_concept_name": "Diabetes mellitus", "valid": True} + { + "relationship_id": "Is a", + "predicate_kind": "HIERARCHY", + "target_concept_id": 301, + "target_concept_name": "Diabetes mellitus", + "valid": True, + } ], "inbound": [], } - def get_neighbors(self, concept_id: int, max_depth: int, predicate_kinds, max_nodes: int, include_edges: bool): + def get_neighbors( + self, + concept_id: int, + max_depth: int, + predicate_kinds, + max_nodes: int, + include_edges: bool, + ): return { "concept_id": concept_id, "neighbor_count": 1, "edge_count": 0, "neighbors": [ - {"concept_id": 301, "concept_name": "Diabetes mellitus", "vocabulary_id": "SNOMED", "domain_id": "Condition", "concept_class_id": "Clinical Finding", "standard_concept": True} + { + "concept_id": 301, + "concept_name": "Diabetes mellitus", + "vocabulary_id": "SNOMED", + "domain_id": "Condition", + "concept_class_id": "Clinical Finding", + "standard_concept": True, + } ], "edges": [], "terminated_early": False, "terminated_reason": None, } - def ground(self, query: str, limit: int, domain: str | None, vocabulary_id: str | None, parent_ids=None): + def ground( + self, + query: str, + limit: int, + domain: str | None, + vocabulary_id: str | None, + parent_ids=None, + ): return { "results": [ { @@ -267,12 +356,32 @@ def ground(self, query: str, limit: int, domain: str | None, vocabulary_id: str "grounding_explanation": {"matched_tier": "PARTIAL"}, } - def find_path(self, source_id: int, target_id: int, max_depth: int, within_domain: bool = True): - return {"found": True, "paths": [{"length": 1, "steps": [{"subject_id": source_id, "object_id": target_id, "predicate": "Is a", "predicate_kind": "HIERARCHY"}]}]} + def find_path( + self, source_id: int, target_id: int, max_depth: int, within_domain: bool = True + ): + return { + "found": True, + "paths": [ + { + "length": 1, + "steps": [ + { + "subject_id": source_id, + "object_id": target_id, + "predicate": "Is a", + "predicate_kind": "HIERARCHY", + } + ], + } + ], + } def map_to_standard(self, vocabulary_id: str, code: str): if code == "44054007": - return {"source": self.get_concept(102), "standard_concepts": [self.get_concept(201)]} + return { + "source": self.get_concept(102), + "standard_concepts": [self.get_concept(201)], + } return {"source": self.get_concept(555), "standard_concepts": []} def concept_views(self, concept_ids): @@ -301,12 +410,27 @@ def concept_views(self, concept_ids): class StubEmbAdapter: - def search(self, query: str, limit: int, domain: str | None, vocabulary: str | None, standard_only: bool, active_only: bool, model_name: str | None): + def search( + self, + query: str, + limit: int, + domain: str | None, + vocabulary: str | None, + standard_only: bool, + active_only: bool, + model_name: str | None, + ): return { "query_text": query, "model_name": model_name or "demo-model", "results": [ - {"concept_id": 104, "concept_name": "Diabetes", "similarity": 0.91, "is_standard": True, "is_active": True} + { + "concept_id": 104, + "concept_name": "Diabetes", + "similarity": 0.91, + "is_standard": True, + "is_active": True, + } ], } @@ -314,7 +438,13 @@ def get_neighbours(self, concept_id: int, limit: int, model_name: str | None): return { "query_concept_id": concept_id, "model_name": model_name or "demo-model", - "results": [{"concept_id": 888, "concept_name": "Near neighbour", "similarity": 0.88}], + "results": [ + { + "concept_id": 888, + "concept_name": "Near neighbour", + "similarity": 0.88, + } + ], } @@ -381,7 +511,9 @@ def test_concept_candidate_bundle_combines_channels_and_standard_mappings(): assert set(result["channels"]) >= {"exact", "normalized", "fulltext", "embedding"} assert any(row["concept_id"] == 102 for row in result["candidate_union"]) - non_standard = next(row for row in result["candidate_union"] if row["concept_id"] == 102) + non_standard = next( + row for row in result["candidate_union"] if row["concept_id"] == 102 + ) assert non_standard["mapped_standard_concepts"][0]["concept_id"] == 201 assert "ancestor_preview" in non_standard @@ -408,7 +540,9 @@ def test_embedding_only_candidate_backfilled_with_identity_metadata(): result = service.concept_candidate_bundle("type 2 diabetes") - emb_only = next(row for row in result["candidate_union"] if row["concept_id"] == 104) + emb_only = next( + row for row in result["candidate_union"] if row["concept_id"] == 104 + ) assert "embedding" in emb_only["retrieved_by"] assert emb_only["vocabulary_id"] == "SNOMED" assert emb_only["concept_code"] == "73211009" @@ -423,7 +557,9 @@ def test_embedding_only_candidate_warns_when_graph_absent_for_backfill(): result = service.concept_candidate_bundle("type 2 diabetes") - emb_only = next(row for row in result["candidate_union"] if row["concept_id"] == 104) + emb_only = next( + row for row in result["candidate_union"] if row["concept_id"] == 104 + ) assert emb_only["vocabulary_id"] is None assert any("identity metadata" in w for w in result["warnings"]) @@ -431,7 +567,9 @@ def test_embedding_only_candidate_warns_when_graph_absent_for_backfill(): def test_concept_nearest_standard_ancestor_selects_nearest_standard_ancestor(): service = build_service() - result = service.concept_nearest_standard_ancestor(query="type 2 diabetes", domain="Condition") + result = service.concept_nearest_standard_ancestor( + query="type 2 diabetes", domain="Condition" + ) assert result["found"] is True assert result["selected_parent"]["concept_id"] == 301 @@ -528,50 +666,34 @@ def test_concept_shared_standard_ancestor_requires_graph(): service.concept_shared_standard_ancestor([801, 802]) -def test_concept_relationship_neighborhood_follows_only_requested_active_edges(): +def test_concept_relationship_neighborhood_bounds_depth_and_assembles_unique_edges(): graph = Mock() edge_data = { - 801: { - "outbound": [ - { - "relationship_id": "Tradename of", - "target_concept_id": 802, - "valid": True, - }, - { - "relationship_id": "Has marketed form", - "target_concept_id": 804, - "valid": True, - }, - ], - "inbound": [], - }, - 802: { - "outbound": [ - { - "relationship_id": "Box of", - "target_concept_id": 803, - "valid": True, - }, - { - "relationship_id": "Has quantified form", - "target_concept_id": 805, - "valid": False, - }, - ], - "inbound": [], - }, - 803: {"outbound": [], "inbound": []}, - 804: {"outbound": [], "inbound": []}, + 801: [ + { + "target_concept_id": 802, + "relationship_id": "Tradename of", + "valid": True, + }, + { + "target_concept_id": 802, + "relationship_id": "Tradename of", + "valid": True, + }, + ], + 802: [{"target_concept_id": 803, "relationship_id": "Box of", "valid": True}], + 803: [{"target_concept_id": 804, "relationship_id": "Box of", "valid": True}], } - graph.get_edges.side_effect = lambda concept_id: edge_data[concept_id] - graph.concept_views.return_value = { - concept_id: {"concept_id": concept_id} for concept_id in (801, 802, 803) + graph.get_relationship_edges.side_effect = lambda cid, relationships, limit: ( + edge_data[cid][:limit] + ) + graph.concept_views.side_effect = lambda ids, **kw: { + cid: {"concept_id": cid} for cid in ids } service = MappingService(StubVocabAdapter(), graph_service=graph) result = service.concept_relationship_neighborhood( - [801], relationship_ids=["Tradename of", "Box of"], max_depth=3 + [801], relationship_ids=["Tradename of", "Box of"], max_depth=2 ) assert result["reached_by_seed"]["801"] == [ @@ -579,10 +701,15 @@ def test_concept_relationship_neighborhood_follows_only_requested_active_edges() {"concept_id": 802, "depth": 1}, {"concept_id": 803, "depth": 2}, ] - assert {edge["relationship_id"] for edge in result["edges"]} == { - "Tradename of", - "Box of", - } + assert result["nodes"] == [{"concept_id": cid} for cid in (801, 802, 803)] + assert result["edges"] == [ + {"subject_id": 801, "object_id": 802, "relationship_id": "Tradename of"}, + {"subject_id": 802, "object_id": 803, "relationship_id": "Box of"}, + ] + assert result["truncated_seeds"] == [] + assert graph.get_relationship_edges.call_count == 2 + graph.get_relationship_edges.assert_any_call(801, ["Box of", "Tradename of"], 1001) + graph.get_relationship_edges.assert_any_call(802, ["Box of", "Tradename of"], 1001) def test_concept_relationship_neighborhood_requires_relationship_ids(): @@ -636,13 +763,33 @@ def test_mapping_evaluate_candidates_computes_summary_metrics(): result = service.mapping_evaluate_candidates( predicted_mappings=[ - {"source_key": "a", "predicted_standard_concept_ids": [201, 202], "domain_id": "Condition"}, - {"source_key": "b", "predicted_standard_concept_ids": [999], "domain_id": "Condition"}, + { + "source_key": "a", + "predicted_standard_concept_ids": [201, 202], + "domain_id": "Condition", + }, + { + "source_key": "b", + "predicted_standard_concept_ids": [999], + "domain_id": "Condition", + }, ], reference_mappings=[ - {"source_key": "a", "reference_standard_concept_id": 201, "domain_id": "Condition"}, - {"source_key": "b", "reference_standard_concept_id": 301, "domain_id": "Condition"}, - {"source_key": "c", "reference_standard_concept_id": 401, "domain_id": "Condition"}, + { + "source_key": "a", + "reference_standard_concept_id": 201, + "domain_id": "Condition", + }, + { + "source_key": "b", + "reference_standard_concept_id": 301, + "domain_id": "Condition", + }, + { + "source_key": "c", + "reference_standard_concept_id": 401, + "domain_id": "Condition", + }, ], ) @@ -667,11 +814,17 @@ def search_exact(self, query, *, active_only=False, parent_ids=None, **kwargs): return super().search_exact(query, **kwargs) def search_normalized(self, query, *, active_only=False, parent_ids=None, **kwargs): - self.received["normalized"] = {"active_only": active_only, "parent_ids": parent_ids} + self.received["normalized"] = { + "active_only": active_only, + "parent_ids": parent_ids, + } return super().search_normalized(query, **kwargs) def search_fulltext(self, query, *, active_only=False, parent_ids=None, **kwargs): - self.received["fulltext"] = {"active_only": active_only, "parent_ids": parent_ids} + self.received["fulltext"] = { + "active_only": active_only, + "parent_ids": parent_ids, + } return super().search_fulltext(query, **kwargs) def navigate_to_standard(self, concept_ids): @@ -692,7 +845,9 @@ def _service_with_recording_vocab(emb=None): def test_active_only_true_propagated_to_all_lexical_channels(): service, vocab = _service_with_recording_vocab(emb=StubEmbAdapter()) - service.concept_candidate_bundle("diabetes", vocabulary_id="ICD10CM", active_only=True) + service.concept_candidate_bundle( + "diabetes", vocabulary_id="ICD10CM", active_only=True + ) assert vocab.received["exact"]["active_only"] is True assert vocab.received["normalized"]["active_only"] is True @@ -702,7 +857,9 @@ def test_active_only_true_propagated_to_all_lexical_channels(): def test_active_only_false_propagated_to_all_lexical_channels(): service, vocab = _service_with_recording_vocab(emb=StubEmbAdapter()) - service.concept_candidate_bundle("diabetes", vocabulary_id="ICD10CM", active_only=False) + service.concept_candidate_bundle( + "diabetes", vocabulary_id="ICD10CM", active_only=False + ) assert vocab.received["exact"]["active_only"] is False assert vocab.received["normalized"]["active_only"] is False @@ -750,12 +907,14 @@ def test_concept_nearest_standard_ancestor_navigates_ancestors_for_exact_non_sta class ExactNonStandardGroundingService: def ground(self, query, *, limit, domain, vocabulary_id, parent_ids=None): return { - "results": [{ - "concept_id": 102, - "concept_name": "Type 2 diabetes mellitus", - "match_kind": "EXACT", - "standard_concept": False, - }], + "results": [ + { + "concept_id": 102, + "concept_name": "Type 2 diabetes mellitus", + "match_kind": "EXACT", + "standard_concept": False, + } + ], "grounding_explanation": {"matched_tier": "EXACT"}, } @@ -791,7 +950,9 @@ def test_embedding_channel_no_parent_ids_note_when_parent_ids_absent(): def test_candidate_bundle_constraints_reflect_active_only_and_parent_ids(): service, _ = _service_with_recording_vocab() - result = service.concept_candidate_bundle("diabetes", active_only=True, parent_ids=[301, 302]) + result = service.concept_candidate_bundle( + "diabetes", active_only=True, parent_ids=[301, 302] + ) constraints = result["constraints"] assert constraints["active_only"] is True @@ -895,7 +1056,9 @@ def test_candidate_bundle_omits_the_normalized_channel_when_it_would_scan(): result = service.concept_candidate_bundle("diabetes") assert "normalized" not in result["channels"] - assert any("normalized channel omitted" in warning for warning in result["warnings"]) + assert any( + "normalized channel omitted" in warning for warning in result["warnings"] + ) def test_candidate_bundle_keeps_the_normalized_channel_when_a_vocabulary_bounds_it(): @@ -904,7 +1067,9 @@ def test_candidate_bundle_keeps_the_normalized_channel_when_a_vocabulary_bounds_ result = service.concept_candidate_bundle("diabetes", vocabulary_id="SNOMED") assert result["channels"]["normalized"]["available"] is True - assert not any("normalized channel omitted" in warning for warning in result["warnings"]) + assert not any( + "normalized channel omitted" in warning for warning in result["warnings"] + ) def test_candidate_bundle_keeps_a_failed_timeout_channel_as_unavailable(): @@ -920,4 +1085,190 @@ def search_normalized_with_plan(self, _query, **_kwargs): assert result["channels"]["normalized"]["retrieval_notes"] == [ "normalized scan timed out" ] - assert any("normalized channel timed out" in warning for warning in result["warnings"]) + assert any( + "normalized channel timed out" in warning for warning in result["warnings"] + ) + + +def test_relationship_neighborhood_bounds_edges_and_reports_truncation(): + graph = Mock() + graph.get_relationship_edges.side_effect = lambda cid, relationships, limit: [ + {"target_concept_id": n, "relationship_id": "Box of", "valid": True} + for n in range(2, 1002) + ][:limit] + graph.concept_views.side_effect = lambda ids, **kw: { + cid: {"concept_id": cid} for cid in ids + } + result = MappingService( + StubVocabAdapter(), graph_service=graph + ).concept_relationship_neighborhood( + [1], relationship_ids=["Box of"], max_depth=1, max_nodes=10 + ) + assert len(result["nodes"]) == 10 + assert len(result["edges"]) == 9 + assert result["truncated_seeds"] == [1] + assert {node["concept_id"] for node in result["nodes"]} == set(range(1, 11)) + assert graph.concept_views.call_count == 1 + + +def test_relationship_neighborhood_rejects_incomplete_metadata(): + graph = Mock() + graph.get_relationship_edges.return_value = [ + {"target_concept_id": 2, "relationship_id": "Box of", "valid": True} + ] + graph.concept_views.return_value = {1: {"concept_id": 1}} + with pytest.raises(GroundworkersError, match="metadata is incomplete") as raised: + MappingService( + StubVocabAdapter(), graph_service=graph + ).concept_relationship_neighborhood( + [1], relationship_ids=["Box of"], max_depth=1 + ) + assert raised.value.code == "QUERY_ERROR" + graph.concept_views.assert_called_once_with((1, 2), strict=True) + + +@pytest.mark.parametrize("seeds", [[999999], [1, 999999], [999999, 888888]]) +def test_relationship_neighborhood_reports_unknown_seeds(seeds): + graph = Mock() + graph.get_relationship_edges.return_value = [] + graph.concept_views.return_value = {1: {"concept_id": 1}} + with pytest.raises(GroundworkersError) as raised: + MappingService( + StubVocabAdapter(), graph_service=graph + ).concept_relationship_neighborhood(seeds, relationship_ids=["Box of"]) + assert raised.value.code == "NOT_FOUND" + assert str(sorted(set(seeds) - {1})) in raised.value.message + graph.concept_views.assert_called_once_with(tuple(sorted(seeds)), strict=True) + + +@pytest.mark.parametrize("seeds", [[1, 20], [20, 1]]) +def test_relationship_neighborhood_gives_each_seed_its_own_edge_budget(seeds): + graph = Mock() + # Dense edges among ten nodes exhaust the 50-edge budget without hitting the node cap. + edge_data = { + cid: [ + {"target_concept_id": target, "relationship_id": "Box of", "valid": True} + for target in range(1, 7) + ] + for cid in range(1, 10) + } + edge_data[1] = [ + {"target_concept_id": target, "relationship_id": "Box of", "valid": True} + for target in range(2, 11) + ] + edge_data[20] = [ + {"target_concept_id": 21, "relationship_id": "Box of", "valid": True} + ] + graph.get_relationship_edges.side_effect = lambda cid, relationships, limit: ( + edge_data.get(cid, [])[:limit] + ) + graph.concept_views.side_effect = lambda ids, **kw: { + cid: {"concept_id": cid} for cid in ids + } + service = MappingService(StubVocabAdapter(), graph_service=graph) + + result = service.concept_relationship_neighborhood( + seeds, relationship_ids=["Box of"], max_depth=2, max_nodes=10 + ) + reverse = service.concept_relationship_neighborhood( + seeds[::-1], relationship_ids=["Box of"], max_depth=2, max_nodes=10 + ) + + assert result["truncated_seeds"] == reverse["truncated_seeds"] == [1] + assert result["nodes"] == reverse["nodes"] + assert result["reached_by_seed"] == reverse["reached_by_seed"] + assert {tuple(sorted(edge.items())) for edge in result["edges"]} == { + tuple(sorted(edge.items())) for edge in reverse["edges"] + } + assert result["max_edges_per_seed"] == 50 + assert len(result["edges"]) == result["returned_edge_count"] == 51 + assert reverse["returned_edge_count"] == 51 + assert {"subject_id": 20, "object_id": 21, "relationship_id": "Box of"} in result[ + "edges" + ] + + +def test_relationship_neighborhood_reuses_fetches_and_merges_overlapping_walks(): + graph = Mock() + edge_data = { + 1: [{"target_concept_id": 2, "relationship_id": "Box of", "valid": True}], + 2: [{"target_concept_id": 3, "relationship_id": "Box of", "valid": True}], + } + graph.get_relationship_edges.side_effect = lambda cid, relationships, limit: ( + edge_data.get(cid, [])[:limit] + ) + graph.concept_views.side_effect = lambda ids, **kw: { + cid: {"concept_id": cid} for cid in ids + } + + result = MappingService( + StubVocabAdapter(), graph_service=graph + ).concept_relationship_neighborhood( + [1, 2], relationship_ids=["Box of"], max_depth=2, max_nodes=10 + ) + + assert result["nodes"] == [{"concept_id": cid} for cid in (1, 2, 3)] + assert result["edges"] == [ + {"subject_id": 1, "object_id": 2, "relationship_id": "Box of"}, + {"subject_id": 2, "object_id": 3, "relationship_id": "Box of"}, + ] + assert result["reached_by_seed"]["2"] == [ + {"concept_id": 2, "depth": 0}, + {"concept_id": 3, "depth": 1}, + ] + assert result["truncated_seeds"] == [] + assert graph.get_relationship_edges.call_count == 3 + graph.get_relationship_edges.assert_any_call(2, ["Box of"], 51) + + +@pytest.mark.parametrize("seeds", [[1, 2], [2, 1]]) +def test_exhausted_relationship_walk_only_probes_and_preserves_other_seed_budgets( + seeds, +): + from groundworkers.services.relationship_neighborhood import RelationshipTraversal + + graph = Mock() + adjacency = {1: [2, 3], 2: list(range(4, 104))} + graph.get_relationship_edges.side_effect = lambda cid, relationships, limit: [ + {"target_concept_id": target, "relationship_id": "Box of"} + for target in adjacency.get(cid, [])[:limit] + ] + traversal = RelationshipTraversal( + graph, ["Box of"], max_depth=2, max_nodes=10, max_edges=2 + ) + walks = {seed: traversal.walk(seed) for seed in seeds} + assert set(walks[1].edges) == {(1, 2, "Box of"), (1, 3, "Box of")} + assert set(walks[2].edges) == {(2, 4, "Box of"), (2, 5, "Box of")} + assert all(walk.truncated for walk in walks.values()) + full_fetches = [ + call.args[0] + for call in graph.get_relationship_edges.call_args_list + if call.args[2] > 1 + ] + assert sorted(full_fetches) == [1, 2] + probes = [ + call.args[0] + for call in graph.get_relationship_edges.call_args_list + if call.args[2] == 1 + ] + assert probes == ([2] if seeds[0] == 1 else []) + assert len(traversal.edge_cache[2]) == 3 + + +def test_exact_edge_budget_with_no_further_edges_is_not_truncated(): + from groundworkers.services.relationship_neighborhood import RelationshipTraversal + + graph = Mock() + graph.get_relationship_edges.side_effect = lambda cid, relationships, limit: [ + {"target_concept_id": target, "relationship_id": "Box of"} + for target in ([2, 3] if cid == 1 else [])[:limit] + ] + result = RelationshipTraversal( + graph, ["Box of"], max_depth=2, max_nodes=10, max_edges=2 + ).walk(1) + assert len(result.edges) == 2 + assert result.truncated is False + assert [ + (call.args[0], call.args[2]) + for call in graph.get_relationship_edges.call_args_list + ] == [(1, 3), (2, 1), (3, 1)] diff --git a/tests/unit/test_migration_boundaries.py b/tests/unit/test_migration_boundaries.py index b7e7855..c4bb3ba 100644 --- a/tests/unit/test_migration_boundaries.py +++ b/tests/unit/test_migration_boundaries.py @@ -107,19 +107,22 @@ def test_migration_dependencies_use_explicit_public_boundaries() -> None: ) dependencies = metadata["project"]["dependencies"] - assert "oa-configurator>=2.0.0,<3" in dependencies - assert "omop-alchemy>=2.0.0,<3" in dependencies - assert "omop-graph>=3.0.0,<4" in dependencies - assert "omop-emb>=3.0.0,<4" in dependencies + assert "oa-configurator>=2.1.0,<3" in dependencies + assert "omop-alchemy>=3.0.0,<4" in dependencies + assert "omop-graph>=4.0.0,<5" in dependencies + assert "omop-emb>=3.1.0,<4" in dependencies assert "omop-llm>=1.0.2,<2" in dependencies assert metadata["project"]["optional-dependencies"]["tui"][0] == ( "groundskeeping>=0.9.1,<1" ) assert metadata["project"]["optional-dependencies"]["embedding-pgvector"] == [ - "omop-emb[pgvector]>=3.0.0,<4" + "omop-emb[pgvector]>=3.1.0,<4" ] assert metadata["project"]["optional-dependencies"]["embedding-faiss"] == [ - "omop-emb[faiss-cpu]>=3.0.0,<4" + "omop-emb[faiss-cpu]>=3.1.0,<4" + ] + assert metadata["project"]["optional-dependencies"]["duckdb"] == [ + "omop-graph[duckdb]>=4.0.0,<5", "omop-emb[duckdbvss]>=3.1.0,<4" ] # RELEASE BLOCKER. No source pins may survive into a publishable artifact. diff --git a/tests/unit/test_normalized_index.py b/tests/unit/test_normalized_index.py index b2d51b7..92963d1 100644 --- a/tests/unit/test_normalized_index.py +++ b/tests/unit/test_normalized_index.py @@ -282,7 +282,7 @@ def create_engines(_resolved, *, register_claims): return primary, vocab monkeypatch.setattr(performance_maintenance, "create_cdm_engines", create_engines) - snapshot = SimpleNamespace(stack=build_cdm_stack()) + snapshot = SimpleNamespace(stack=build_cdm_stack(cdm_schema="primary", vocab_schema="vocabulary")) with performance_maintenance._postgres_cdm( snapshot, purpose="Normalized-name indexes" diff --git a/tests/unit/test_omop_emb_adapter.py b/tests/unit/test_omop_emb_adapter.py index 54d927d..b4965ae 100644 --- a/tests/unit/test_omop_emb_adapter.py +++ b/tests/unit/test_omop_emb_adapter.py @@ -210,7 +210,7 @@ def test_encode_uses_query_role_and_returns_vector_payload(monkeypatch): "text": "diabetes mellitus", "model_name": "demo-model", "dimensions": 3, - "vector": [0.1, 0.2, 0.3], + "vector": pytest.approx([0.1, 0.2, 0.3]), } assert backend.calls == [ { diff --git a/tests/unit/test_predicate_mapping_classification.py b/tests/unit/test_predicate_mapping_classification.py index 543f9d0..789b30d 100644 --- a/tests/unit/test_predicate_mapping_classification.py +++ b/tests/unit/test_predicate_mapping_classification.py @@ -37,7 +37,7 @@ def test_bundled_mapping_classes_snomed_defining_attributes_as_attributes() -> None: - mapping_file = files("groundworkers") / "config" / "predicate_mapping.csv" + mapping_file = files("omop_graph") / "data" / "predicate_mapping.csv" with mapping_file.open(newline="") as handle: classes = {row["r_id"]: (row["class"], row["subclass"]) for row in csv.DictReader(handle)} diff --git a/tests/unit/test_relationship_edges.py b/tests/unit/test_relationship_edges.py new file mode 100644 index 0000000..d397dec --- /dev/null +++ b/tests/unit/test_relationship_edges.py @@ -0,0 +1,75 @@ +from datetime import date + +import pytest +from oa_configurator import CDMDatabaseConfig, ConnectionConfig, Resolver +from omop_alchemy.cdm.model.vocabulary import Concept_Relationship +from omop_alchemy.config import create_cdm_engines +from omop_alchemy.cross_database import cdm_sessionmaker + +from groundworkers.adapters.omop_graph import OmopGraphAdapter +from tests.support.stack_config import build_cdm_stack + + +@pytest.mark.parametrize("split", [False, True]) +def test_relationship_edges_filters_orders_and_limits_active_outbound_rows( + tmp_path, split +): + stack = build_cdm_stack(sqlite_path=str(tmp_path / "primary.db")) + if split: + stack.connections["vocab_db"] = ConnectionConfig( + dialect="sqlite", database_name=str(tmp_path / "vocabulary.db") + ) + stack.databases["cdm_db"] = CDMDatabaseConfig( + connection="cdm_main", vocab_connection="vocab_db" + ) + resolved = Resolver(stack).resolve_database("cdm_db") + primary, vocab = create_cdm_engines(resolved, register_claims=False) + try: + Concept_Relationship.__table__.create(vocab) + with vocab.begin() as connection: + connection.execute( + Concept_Relationship.__table__.insert(), + [ + { + "concept_id_1": source, + "concept_id_2": target, + "relationship_id": relationship, + "invalid_reason": invalid, + "valid_start_date": date(2000, 1, 1), + "valid_end_date": date(2099, 12, 31), + } + for source, target, relationship, invalid in [ + (1, 9, "Box of", ""), + (1, 4, "Tradename of", " "), + (1, 4, "Box of", None), + (1, 3, "Box of", ""), + (1, 2, "Box of", "D"), + (1, 2, "Is a", ""), + (2, 1, "Box of", ""), + ] + ], + ) + session_factory = cdm_sessionmaker(resolved, primary=primary, vocab=vocab) + adapter = OmopGraphAdapter(primary, cdm_session_factory=session_factory) + expected = [ + { + "target_concept_id": target, + "relationship_id": relationship, + "valid": True, + } + for target, relationship in [ + (3, "Box of"), + (4, "Box of"), + (4, "Tradename of"), + (9, "Box of"), + ] + ] + + assert adapter.relationship_edges(1, ["Tradename of", "Box of"], 10) == expected + assert ( + adapter.relationship_edges(1, ["Tradename of", "Box of"], 2) == expected[:2] + ) + finally: + primary.dispose() + if vocab is not primary: + vocab.dispose() diff --git a/tests/unit/test_setup_databases.py b/tests/unit/test_setup_databases.py index 0f7b404..93d3c54 100644 --- a/tests/unit/test_setup_databases.py +++ b/tests/unit/test_setup_databases.py @@ -506,3 +506,14 @@ def _write_config(tmp_path: Path, content: str) -> Path: path = tmp_path / "config.toml" path.write_text(content, encoding="utf-8") return path + + +@pytest.mark.parametrize("message", [ + 'IO Error: Could not set lock on file "secret/path.duckdb"', + "Connection Error: Can't open a connection to same database file with a different configuration than existing connections", +]) +def test_duckdb_connection_conflicts_offer_safe_preparation_guidance(message): + failure = classify_connection_error(RuntimeError(message)) + assert "conflicting" in failure.detail + assert "close other readers" in failure.next_action + assert "secret" not in failure.detail diff --git a/tests/unit/test_setup_embeddings.py b/tests/unit/test_setup_embeddings.py index 1bcbc8a..3947bd1 100644 --- a/tests/unit/test_setup_embeddings.py +++ b/tests/unit/test_setup_embeddings.py @@ -3,6 +3,7 @@ from datetime import UTC, datetime from pathlib import Path +import pytest from oa_configurator import Resolver from omop_emb.backends.index_config import FlatIndexConfig from omop_emb.model_registry import EmbeddingModelRecord @@ -543,3 +544,34 @@ def fail(_store): assert report.coverage.available is False assert report.coverage.blocker is not None assert "database-password" not in report.coverage.blocker + + +@pytest.mark.parametrize("target", ["existing", "missing", "directory", "relative", "tilde"]) +def test_duckdb_configuration_reports_absolute_file_metadata( + tmp_path, monkeypatch, target +): + stack = build_embedding_stack() + stack.connections["embedding_main"].dialect = "duckdb" + stack.vector_stores["embedding_store"].backend_type = "duckdbvss" + asset = tmp_path / "vectors.duckdb" + if target == "directory": + asset.mkdir() + elif target != "missing": + asset.touch() + elsewhere = tmp_path / "working" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("HOME", str(tmp_path)) + filename = ( + asset.name + if target == "relative" + else "~/" + asset.name + if target == "tilde" + else str(asset) + ) + stack.connections["embedding_main"].database_name = filename + configuration = load_embedding_configuration( + _snapshot(stack, tmp_path / "config.toml") + ) + assert configuration.database_path == filename + assert configuration.database_path_exists is (target == "existing") diff --git a/tests/unit/test_tui_state.py b/tests/unit/test_tui_state.py index 3ab91e5..70a3b51 100644 --- a/tests/unit/test_tui_state.py +++ b/tests/unit/test_tui_state.py @@ -72,7 +72,10 @@ def test_setup_session_accepts_explicit_read_only_ownership(tmp_path: Path) -> N ) assert session.configuration.ownership.editable is False - assert session.configuration.ownership.source_label == "Container supplied configuration" + assert ( + session.configuration.ownership.source_label + == "Container supplied configuration" + ) def test_malformed_config_is_presented_without_secret(tmp_path: Path) -> None: @@ -249,7 +252,46 @@ def test_embeddings_presenter_explains_faiss_is_not_a_backend() -> None: "embedding_store · faiss", "Unsupported", ) - assert "sqlitevec or pgvector" in str(view.message) + assert "sqlitevec, pgvector, or duckdbvss" in str(view.message) + + +@pytest.mark.parametrize("checked", [False, True]) +def test_embeddings_presenter_recognizes_duckdb_stores(checked) -> None: + configuration = _embedding_configuration( + backend="duckdbvss", + database_path="/assets/emb.duckdb", + database_path_exists=True, + ) + report = ( + EmbeddingCoverageReport( + configuration=configuration, + coverage=CoverageSnapshot( + scope=CoverageScope( + model_name=configuration.model_name, + metric="cosine", + vocabularies=(), + standard_only=True, + valid_only=False, + ), + available=True, + ), + index=EmbeddingIndexSnapshot( + model_name=configuration.model_name, registered=True + ), + ) + if checked + else None + ) + view = EmbeddingsPresenter().landing( + database_ready=True, configuration=configuration, coverage=report + ) + assert view.rows[0].cells == ( + "Store", + "embedding_store · duckdbvss · /assets/emb.duckdb", + "Found", + ) + assert view.status.name != "ERROR" + assert "Choose a supported vector store backend" not in str(view.message) def test_embeddings_presenter_leaves_index_readiness_to_performance() -> None: diff --git a/tests/unit/test_vector_store_setup.py b/tests/unit/test_vector_store_setup.py index d17d4fb..4c1055d 100644 --- a/tests/unit/test_vector_store_setup.py +++ b/tests/unit/test_vector_store_setup.py @@ -144,9 +144,7 @@ def test_separate_store_location_round_trips_on_either_connection( tmp_path: Path, same_connection: bool ) -> None: path = tmp_path / "config.toml" - stack = build_cdm_stack( - cdm_schema="clinical", vocab_schema="vocabulary" - ) + stack = build_cdm_stack(cdm_schema="clinical", vocab_schema="vocabulary") if not same_connection: stack.connections["vectors"] = ConnectionConfig( dialect="postgresql+psycopg", @@ -159,14 +157,19 @@ def test_separate_store_location_round_trips_on_either_connection( database_name = "cdm_db_vectors" if same_connection else "separate_vectors" connection_name = "cdm_main" if same_connection else "vectors" - assert _drive( - path, - location=STORE_LOCATION_SEPARATE, - backend_type="pgvector", - store_database_name=database_name, - store_connection_name=connection_name, - store_schema_name="vectors", - ).apply().status.value == "applied" + assert ( + _drive( + path, + location=STORE_LOCATION_SEPARATE, + backend_type="pgvector", + store_database_name=database_name, + store_connection_name=connection_name, + store_schema_name="vectors", + ) + .apply() + .status.value + == "applied" + ) reopened = ConfigWizardController( vector_store_setup_workflow(MutationOperation.UPDATE), @@ -208,14 +211,120 @@ def test_the_cdm_location_needs_no_database_answers(tmp_path: Path) -> None: def test_the_backend_choice_is_carried_through(tmp_path: Path) -> None: path = _stack_path(tmp_path) - _drive( - path, location=STORE_LOCATION_SEPARATE, backend_type="pgvector" - ).apply() + _drive(path, location=STORE_LOCATION_SEPARATE, backend_type="pgvector").apply() stack = load_stack_config(path) assert stack.vector_stores["embeddings"].backend_type == "pgvector" +@pytest.mark.parametrize("alias", ["relative", "tilde", "symlink", "hardlink", "separate"]) +def test_duckdb_store_setup_compares_physical_files( + tmp_path: Path, monkeypatch, alias +) -> None: + cdm_path = tmp_path / "cdm.duckdb" + cdm_path.touch() + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + if alias in {"symlink", "hardlink"}: + linked = tmp_path / "alias.duckdb" + if alias == "hardlink": + linked.hardlink_to(cdm_path) + else: + linked.symlink_to(cdm_path) + store_path = str(linked) + else: + store_path = { + "relative": "./cdm.duckdb", + "tilde": "~/cdm.duckdb", + "separate": str(tmp_path / "emb.duckdb"), + }[alias] + if alias == "separate": + Path(store_path).touch() + stack = build_cdm_stack() + stack.connections["cdm_main"] = ConnectionConfig( + dialect="duckdb", database_name=str(cdm_path) + ) + stack.connections["emb"] = ConnectionConfig( + dialect="duckdb", database_name=store_path + ) + config_path = tmp_path / "config.toml" + save_stack_config(stack, config_path) + controller = ConfigWizardController( + vector_store_setup_workflow(MutationOperation.CREATE), + GroundworkersConfigMutationService(config_path), + ) + controller.start() + assert not controller.submit({"store_location": STORE_LOCATION_SEPARATE}).issues + assert not controller.submit( + {"store_entry_name": "vectors", "backend_type": "duckdbvss"} + ).issues + result = controller.submit( + {"store_database_name": "emb", "store_connection_name": "emb"} + ) + if alias == "separate": + assert not result.issues + assert result.snapshot.can_apply + else: + assert [issue.field_key for issue in result.issues] == ["store_connection_name"] + assert ( + "absolute" if alias in {"relative", "tilde"} else "separate from the CDM" + ) in result.issues[0].message + assert not result.snapshot.can_apply + + +@pytest.mark.parametrize( + "target", ["missing_parent", "directory", "memory", "home_alias"] +) +def test_duckdb_store_setup_rejects_invalid_file_targets( + tmp_path: Path, monkeypatch, target +) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + stack = build_cdm_stack() + cdm = tmp_path / "cdm.duckdb" + cdm.touch() + embedding = tmp_path / "emb.duckdb" + if target == "missing_parent": + embedding = tmp_path / "missing" / "emb.duckdb" + elif target == "directory": + embedding.mkdir() + elif target == "home_alias": + embedding.touch() + stack.connections["cdm_main"] = ConnectionConfig( + dialect="duckdb", database_name=str(cdm) + ) + stack.connections["emb"] = ConnectionConfig( + dialect="duckdb", + database_name=":memory:" + if target == "memory" + else "~/emb.duckdb" + if target == "home_alias" + else str(embedding), + ) + path = tmp_path / "config.toml" + save_stack_config(stack, path) + before = path.read_bytes() + controller = ConfigWizardController( + vector_store_setup_workflow(MutationOperation.CREATE), + GroundworkersConfigMutationService(path), + ) + controller.start() + assert not controller.submit({"store_location": STORE_LOCATION_SEPARATE}).issues + assert not controller.submit( + {"store_entry_name": "vectors", "backend_type": "duckdbvss"} + ).issues + result = controller.submit( + {"store_database_name": "emb", "store_connection_name": "emb"} + ) + assert [issue.field_key for issue in result.issues] == ["store_connection_name"] + assert ( + "absolute" if target in {"memory", "home_alias"} else "existing" + ) in result.issues[0].message + assert not result.snapshot.can_apply + assert path.read_bytes() == before + assert target in {"directory", "home_alias"} or not embedding.exists() + + def test_a_backend_the_cdm_cannot_serve_is_refused(tmp_path: Path) -> None: """pgvector against a SQLite CDM would only fail later, in omop-emb.""" path = _stack_path(tmp_path) @@ -297,3 +406,27 @@ def test_an_unconfigured_store_still_shows_a_row(tmp_path: Path) -> None: row = next(row for row in view.rows if row.key == EMBEDDING_TARGET_KEY) assert "Not configured" in row.cells + + +def test_duckdb_store_configuration_accepts_a_new_file_without_creating_it(tmp_path): + stack = build_cdm_stack() + cdm = tmp_path / "cdm.duckdb" + cdm.touch() + embedding = tmp_path / "new-emb.duckdb" + stack.connections["cdm_main"] = ConnectionConfig( + dialect="duckdb", database_name=str(cdm) + ) + stack.connections["emb"] = ConnectionConfig( + dialect="duckdb", database_name=str(embedding) + ) + path = tmp_path / "config.toml" + save_stack_config(stack, path) + controller = _drive( + path, + location=STORE_LOCATION_SEPARATE, + backend_type="duckdbvss", + store_connection_name="emb", + ) + assert controller.review().snapshot.can_apply + assert controller.apply().status.value == "applied" + assert not embedding.exists() diff --git a/uv.lock b/uv.lock index 9fa331f..5dea50e 100644 --- a/uv.lock +++ b/uv.lock @@ -315,11 +315,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.7.22" +version = "2026.10.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, ] [[package]] @@ -742,6 +742,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] +[[package]] +name = "duckdb" +version = "1.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/0b/d65ea3be00ea79aa276a8388bec588a9cbf409ce637c6d306e5316210d15/duckdb-1.5.6.tar.gz", hash = "sha256:166a91dbfacfc0c9f08cc76c0243cb6d3d4296bfab5bad72a3cfb63140a5b7c8", size = 18032957, upload-time = "2026-09-28T13:38:37.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/d5/d0ab77a0a1702a43171c93874f44c1f6481e30038bd3987df0d77a16a5c6/duckdb-1.5.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:48d07d0651aaeac2c3974afd37599970154b7b79b54c18f27c319c14ccf98d9d", size = 32810486, upload-time = "2026-09-28T13:37:47.254Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cd/b22201de5377faa3be6c38d5f3eaa504cb480392a448bed6a4d2239469b4/duckdb-1.5.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79de3dfa8705b1ba0d59e7e3252e40ff399e0afd12f485502a6c7bf7c2fd809a", size = 17405278, upload-time = "2026-09-28T13:37:50.135Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6d/f9cfb1493bbdc2f095693a402e42dce1192077f9e11573f00baed6a748de/duckdb-1.5.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dcccce20965e6986cd083fdf192c461685ad0b93cd1ccd0b2a8207f1185f078b", size = 15532943, upload-time = "2026-09-28T13:37:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/53/04/f65ccfaa5a833f2e570c4a140f03c8f95da416da9fe8ed08401f81f8242a/duckdb-1.5.6-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce89a1025a5317ebe9c520876c48032b5247ac574865486648b1a004f6009875", size = 19454940, upload-time = "2026-09-28T13:37:55.732Z" }, + { url = "https://files.pythonhosted.org/packages/4c/99/be75c788a492f8d77b7a1cdc1b19939ae7be0007f2028691ad371a1a33ee/duckdb-1.5.6-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc9619ed7d4ffa117b5155d84b44794366bb6635178d78ed5e13a6024845c757", size = 21568087, upload-time = "2026-09-28T13:37:58.191Z" }, + { url = "https://files.pythonhosted.org/packages/b5/95/889f8508960e47c0a7c75cc5bf57cde8512fc24f8db7b3129cca5388da42/duckdb-1.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:09ff51b230219f0d8b47fc8a1e17fb595ba9fab0c3d96a6de4d00b8ff86b3cf1", size = 13190189, upload-time = "2026-09-28T13:38:00.407Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c9/baab503364a68309f8368c88e77f5341e7d94927bdf3e6d703f0e5035f3e/duckdb-1.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:b8d795c8b2d5634b3269f974aa97f1fdf878f62f032317a52252a151b693fb1e", size = 14021977, upload-time = "2026-09-28T13:38:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/a476197fcba557738a588ec844747a19bc0a24b0e6f1809e308f29d68c0e/duckdb-1.5.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ae352646374cacf48e9981cf031191c494865192fc436d13667a2531fc5d1da3", size = 32810376, upload-time = "2026-09-28T13:38:05.148Z" }, + { url = "https://files.pythonhosted.org/packages/0c/6d/5466a2b53ddd557644dfa47a763f68748efccdf282e6ae7c4f1bcfb3da69/duckdb-1.5.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a1261e90785e9d29953293e44f60fa073bd1137098924e8de21a037a861b051", size = 17405385, upload-time = "2026-09-28T13:38:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/d4/a0/bf87071170835ee4a34fe764fc11c1c6e7040a0e021b36c1b6f834a4c22f/duckdb-1.5.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97dd7a555b8f5298b76bc7d48a11cb2c64336e8de9bfde783cffb86ea9f54807", size = 15533132, upload-time = "2026-09-28T13:38:09.681Z" }, + { url = "https://files.pythonhosted.org/packages/31/e0/38095c8e140ecfbe847519ac07bcba94301b8fbb76b2870015e33e07f179/duckdb-1.5.6-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:364992ba1089a2b327391cfcb68fd0bd0ce9090cf293baef861a0ba6847abfee", size = 19454994, upload-time = "2026-09-28T13:38:11.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/61dd2876bbaa69cf77d7b5c620e52e8b25faae7096f4d2e4a812b52095d7/duckdb-1.5.6-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:644f54ce99b3b61844bc9a3fe80e0aecb1ea4084b1fffc4396d1569db6111679", size = 21568700, upload-time = "2026-09-28T13:38:14.258Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/100730e7785e85268be4d4d5bd62cfc8314e261d2f42efa208243eef35cb/duckdb-1.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:ced693d33ddcee2e5345f077d342c87d2aaa80e41c514e64c9ff2d4e5963c251", size = 13190707, upload-time = "2026-09-28T13:38:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2e/bc7f44eab4e89ee5c1cb427bb1168ad021d985042e6841ec0694c3d3d501/duckdb-1.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:41ecc75bb9328d72d154a705c1a653d2c5c60f686a5c0c6578aa80020753c884", size = 14020962, upload-time = "2026-09-28T13:38:19.007Z" }, + { url = "https://files.pythonhosted.org/packages/fb/62/a8a30a4c6b94c0861d348ed5633b963f6745a5525527530f02f3c1a7c931/duckdb-1.5.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:aa21d2ad803b2524326e8622d7d96b2bb1ff1d5b60368e1978ee805df9c21fb3", size = 32828003, upload-time = "2026-09-28T13:38:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/71/b7/1dcca0005eb8c67adf9fc06bf0cbb1d2bf4ea1974cc89e7a7c2ad66aac28/duckdb-1.5.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8a1b2ad27d414068cbca06c55cfa802eece10f86ea4812ff082f8ab4cb25fc85", size = 17413912, upload-time = "2026-09-28T13:38:23.915Z" }, + { url = "https://files.pythonhosted.org/packages/93/b0/e3ac175443550f3464f2d95731a8b0aae9b4dc3875c3a186c352262b43c2/duckdb-1.5.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c79c6d222b1d015cde73b5139087186b00db65357fb4e2c94c2308fbbf465a72", size = 15543122, upload-time = "2026-09-28T13:38:26.317Z" }, + { url = "https://files.pythonhosted.org/packages/9d/08/cc510a7952aba69d5cdca17f3ef61c95713d86143f2ee9aa3e097d38f50b/duckdb-1.5.6-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1052b8050ef5696e2c0d8c836949c72f3dd11f0690466acbea739613e8e2750b", size = 19457946, upload-time = "2026-09-28T13:38:28.877Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a5/6f8099d9a5a02ddff89e5c85875df3465054845b0920fb0703fbdf8dd2ec/duckdb-1.5.6-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19c5e485e59613b8878d1670bcaa7a010f53c5a4da5ae8e08863e5e529ca6182", size = 21575132, upload-time = "2026-09-28T13:38:31.231Z" }, + { url = "https://files.pythonhosted.org/packages/9f/58/762f7159662d7859e201fa05ca29f306795daeabf84f3e087215a966b001/duckdb-1.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:ebcbd09cd8578ab1093393e9b16289cda0e8f1791ac595bf00eb5bad75c3cf00", size = 13713963, upload-time = "2026-09-28T13:38:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/64d165db322de13f5c3e75d377b6b9694df1821155ad1fa4b14b04601abc/duckdb-1.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:820a8384faef11cd86068ea48c5da57ce2d8f1c7b3d2bdb9be3398317a7c3728", size = 14514368, upload-time = "2026-09-28T13:38:35.676Z" }, +] + +[[package]] +name = "duckdb-sqlalchemy" +version = "1.5.6.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "duckdb" }, + { name = "packaging" }, + { name = "pytz" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/e2/8be0e825bc1579201cb0cf748f234f75a528b0a8ff707f54b779f11a5d21/duckdb_sqlalchemy-1.5.6.8.tar.gz", hash = "sha256:fe8bf0c1d46721fc87c7a905ff17841c2a27f849c059762ce3edb0e45f28343c", size = 211413, upload-time = "2026-11-02T11:10:19.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/3e/570caa63a404778dabc1340522a8d3e0c5e11b0893fe31265ecb040e313a/duckdb_sqlalchemy-1.5.6.8-py3-none-any.whl", hash = "sha256:f6e8b1e956c9d8236a406c4eb06cb3688cc0375bc7da67b625cc38199c1d1d9b", size = 85289, upload-time = "2026-11-02T11:10:18.04Z" }, +] + [[package]] name = "editorconfig" version = "0.17.1" @@ -1139,6 +1183,8 @@ dev = [ { name = "mkdocs-mermaid2-plugin" }, { name = "mkdocstrings-python" }, { name = "mypy" }, + { name = "omop-emb", extra = ["duckdbvss"] }, + { name = "omop-graph", extra = ["duckdb"] }, { name = "pytest" }, { name = "pytest-cov" }, { name = "requests" }, @@ -1150,6 +1196,10 @@ dev = [ docx = [ { name = "python-docx" }, ] +duckdb = [ + { name = "omop-emb", extra = ["duckdbvss"] }, + { name = "omop-graph", extra = ["duckdb"] }, +] embedding-artifacts = [ { name = "h5py" }, ] @@ -1185,12 +1235,16 @@ requires-dist = [ { name = "mkdocstrings-python", marker = "extra == 'dev'", specifier = ">=2.0.1" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8" }, { name = "numpy", specifier = ">=1.26,<3" }, - { name = "oa-configurator", specifier = ">=2.0.0,<3" }, - { name = "omop-alchemy", specifier = ">=2.0.0,<3" }, - { name = "omop-emb", specifier = ">=3.0.0,<4" }, - { name = "omop-emb", extras = ["faiss-cpu"], marker = "extra == 'embedding-faiss'", specifier = ">=3.0.0,<4" }, - { name = "omop-emb", extras = ["pgvector"], marker = "extra == 'embedding-pgvector'", specifier = ">=3.0.0,<4" }, - { name = "omop-graph", specifier = ">=3.0.0,<4" }, + { name = "oa-configurator", specifier = ">=2.1.0,<3" }, + { name = "omop-alchemy", specifier = ">=3.0.0,<4" }, + { name = "omop-emb", specifier = ">=3.1.0,<4" }, + { name = "omop-emb", extras = ["duckdbvss"], marker = "extra == 'dev'", specifier = ">=3.1.0,<4" }, + { name = "omop-emb", extras = ["duckdbvss"], marker = "extra == 'duckdb'", specifier = ">=3.1.0,<4" }, + { name = "omop-emb", extras = ["faiss-cpu"], marker = "extra == 'embedding-faiss'", specifier = ">=3.1.0,<4" }, + { name = "omop-emb", extras = ["pgvector"], marker = "extra == 'embedding-pgvector'", specifier = ">=3.1.0,<4" }, + { name = "omop-graph", specifier = ">=4.0.0,<5" }, + { name = "omop-graph", extras = ["duckdb"], marker = "extra == 'dev'", specifier = ">=4.0.0,<5" }, + { name = "omop-graph", extras = ["duckdb"], marker = "extra == 'duckdb'", specifier = ">=4.0.0,<5" }, { name = "omop-llm", specifier = ">=1.0.2,<2" }, { name = "omop-semantics", specifier = ">=0.6,<1" }, { name = "openpyxl", marker = "extra == 'all-source'", specifier = ">=3.1,<4" }, @@ -1216,7 +1270,7 @@ requires-dist = [ { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.59" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.29,<1.0" }, ] -provides-extras = ["all-source", "dev", "docx", "embedding-artifacts", "embedding-faiss", "embedding-pgvector", "pdf", "tui", "xlsx"] +provides-extras = ["all-source", "dev", "docx", "duckdb", "embedding-artifacts", "embedding-faiss", "embedding-pgvector", "pdf", "tui", "xlsx"] [[package]] name = "h11" @@ -2494,7 +2548,7 @@ wheels = [ [[package]] name = "oa-configurator" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -2505,9 +2559,9 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/62/64bf398b8fe381d138446199be69bc437ef93fdb70aab48cebf51467f6ea/oa_configurator-2.0.0.tar.gz", hash = "sha256:26406e90aeadd4c747646763b80f16025c4c6638afec687517788bc537d0d581", size = 278382, upload-time = "2026-10-10T22:48:42.275Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/5a/e1061864a6f9abc9c49e26ab84a51dfc8f40616906c853e01ccbc022b477/oa_configurator-2.1.0.tar.gz", hash = "sha256:89e33ab58bd428081b1c1c8ce1b4ad7d4a7d4b4627bcbc20d8cd6502811f627c", size = 298778, upload-time = "2026-10-26T21:01:07.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/9b/99f0073d97de3545d48cf28c8f39b724d543baf83e8af8d0ba5c8bffbb01/oa_configurator-2.0.0-py3-none-any.whl", hash = "sha256:2186beb5567850ba5bfe4950c2820b909d3746c6fa233a6074a85396a343af35", size = 118027, upload-time = "2026-10-10T22:48:40.571Z" }, + { url = "https://files.pythonhosted.org/packages/59/44/38cdd910fe7544b118d7a5fc741417f1be0a4a95fdb754d8d5f3a016b9f9/oa_configurator-2.1.0-py3-none-any.whl", hash = "sha256:44c2fc43cf01398e598fc4d0fd4b17f9a3090cc35b6a378cdaf8e40fbfab0a0a", size = 126679, upload-time = "2026-10-26T21:01:05.893Z" }, ] [[package]] @@ -2581,7 +2635,7 @@ wheels = [ [[package]] name = "omop-alchemy" -version = "2.0.0" +version = "3.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "oa-configurator" }, @@ -2593,14 +2647,19 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/4b/a925c1db558070a4a807a38456a90dab33ab766e18cd3de890dae94295ca/omop_alchemy-2.0.0.tar.gz", hash = "sha256:403848b2c377ef6200119f4d5280a1b7e1abe0c42e8681fe16a74ee87fa82259", size = 734827, upload-time = "2026-10-10T23:39:29.99Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/a4/330e86e324759626cbb48b6faeaa7bd903a480b4687515e486bdcdfaa3e2/omop_alchemy-3.0.0.tar.gz", hash = "sha256:084a873a84aa378614ad75b7791de6d5fa8dfdc7800484a6c0713d9f36f01c01", size = 757452, upload-time = "2026-11-05T06:16:28.04Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/d2/2c271ce7d09b978bf812b698baf9000f1bc56afa2d1915b58c9b079875db/omop_alchemy-2.0.0-py3-none-any.whl", hash = "sha256:17cdbf3d901f23da339b7e4d4598f22f178499733fa326884de6e0de40c78f02", size = 298023, upload-time = "2026-10-10T23:39:28.182Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b9/674e2dfac874a5c078f30e2cb63453c3bb40b78bbeea52bfbbaa7e139864/omop_alchemy-3.0.0-py3-none-any.whl", hash = "sha256:d369a452456364b4d679741cd2e6f2151aa4a070274714566ca56df019a9eeae", size = 307688, upload-time = "2026-11-05T06:16:26.131Z" }, +] + +[package.optional-dependencies] +duckdb = [ + { name = "duckdb-sqlalchemy" }, ] [[package]] name = "omop-emb" -version = "3.0.0" +version = "3.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h5py" }, @@ -2615,12 +2674,15 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/68/54b0a9538da687f38e995d16fb335d585cd7dbca423be5874fb3f07217ae/omop_emb-3.0.0.tar.gz", hash = "sha256:6ceabae683e9f36cc9250fb0a62a6074a588b5ff5594e8fd8b06c1dcec5a826e", size = 249683, upload-time = "2026-10-11T00:20:20.468Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/5c/99fd350084bfc4b01ae4a257e8cb9b3c97b637f3ea1bc5000587e4430e63/omop_emb-3.1.0.tar.gz", hash = "sha256:8ebde36de4c269fd0c67d41bfda1b0cfee5ef4ac6fdd8912a42112441a54ebbc", size = 290209, upload-time = "2026-11-05T21:26:40.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/a6/e564d7d43b294c592a93f1da737a7e836cb50f592a9127e6b10ef4383471/omop_emb-3.0.0-py3-none-any.whl", hash = "sha256:0e78c7c0ef01a71d1a62498d9fab494aab81d7fa40aa1e712ec097530c5df730", size = 94080, upload-time = "2026-10-11T00:20:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ea/ef867376e7ad86acadbca8d23fba244dddbd015847f7578ec47f11cee52d/omop_emb-3.1.0-py3-none-any.whl", hash = "sha256:a0c11f3227d01d86d736c8cc009d79f66997d105189462af10651bbc1735a901", size = 113798, upload-time = "2026-11-05T21:26:39.019Z" }, ] [package.optional-dependencies] +duckdbvss = [ + { name = "duckdb-sqlalchemy" }, +] faiss-cpu = [ { name = "faiss-cpu" }, ] @@ -2631,7 +2693,7 @@ pgvector = [ [[package]] name = "omop-graph" -version = "3.0.0" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "oa-configurator" }, @@ -2642,9 +2704,14 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2d/40/609efc228073c4d712a4f0328229fa1ffaef317ce180cd52189ff607090b/omop_graph-3.0.0.tar.gz", hash = "sha256:2184dab10323a541b400d42c94e603f33286b36b0d66a7797ce4f35b64d82104", size = 470552, upload-time = "2026-10-11T00:33:15.749Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/8c/24f71eaf8fca19f18467dbdbb22943a40744bc42d6f5bb0bae1340b47a1e/omop_graph-4.0.0.tar.gz", hash = "sha256:d7f31d667150b0904beec40bcb93ea73b6b101bc2c71f6c87c0004a8d0f9f161", size = 478365, upload-time = "2026-11-06T04:38:47.823Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/14/e86bfac20c51dc4f70596d5b69d1107093b96356543157d7db1a555ec262/omop_graph-3.0.0-py3-none-any.whl", hash = "sha256:7bf0c99f325ba0796c8b0ff2239e68657a08686dcdd0b34aeeda750bd4566e44", size = 126600, upload-time = "2026-10-11T00:33:13.968Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a7/640ccdc1899d5d47d2fcabfcb18ae6b6683e4c56292952e4c40649d2656a/omop_graph-4.0.0-py3-none-any.whl", hash = "sha256:27654e1d2ecb9e58b20bd9d1d263b7e056b8cad65de4434766f81a7189b09a0f", size = 127067, upload-time = "2026-11-06T04:38:45.983Z" }, +] + +[package.optional-dependencies] +duckdb = [ + { name = "omop-alchemy", extra = ["duckdb"] }, ] [[package]] @@ -2742,18 +2809,17 @@ wheels = [ [[package]] name = "orm-loader" -version = "2.0.0" +version = "3.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chardet" }, { name = "oa-configurator" }, - { name = "pandas" }, { name = "pyarrow" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/cd/b3a29d383b80e1c282a3ee12a28f00b5608802763dd98ed7b3adc637e630/orm_loader-2.0.0.tar.gz", hash = "sha256:73232ecb5f1474738e54f30fae89999620d57cc41456ae799669a74c690a76b3", size = 171586, upload-time = "2026-10-10T23:22:15.966Z" } +sdist = { url = "https://files.pythonhosted.org/packages/16/e4/3535ecb78ba058de22308d1b2abd6d143586c22a39e15526486cdabb845b/orm_loader-3.0.0.tar.gz", hash = "sha256:865d14bf853a312acb6f43c6d63825f73436f41d219aae7caae086da915eb5cd", size = 223323, upload-time = "2026-11-05T04:55:59.646Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/61/6687b9c1602deb1c364d4653c641698e3de298b9c4f5dc98c59698455ac7/orm_loader-2.0.0-py3-none-any.whl", hash = "sha256:a118ddc3fcb611d658b7be52f616b641804ae65067efd05753d1bc14a03b52c9", size = 74959, upload-time = "2026-10-10T23:22:14.645Z" }, + { url = "https://files.pythonhosted.org/packages/a0/96/22b96702ab51b867994839589408509a0e4173d0d5b27d4d7cf1c8d89ed6/orm_loader-3.0.0-py3-none-any.whl", hash = "sha256:0f91ce37c1bc930113d9476130521cb74b755010705173c281cf1d6262a1a4ef", size = 96094, upload-time = "2026-11-05T04:55:57.896Z" }, ] [[package]] @@ -3576,6 +3642,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] +[[package]] +name = "pytz" +version = "2026.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/b0/168030b0bfcc334cf09fa3313afdb888d892c8a0027324683d1de0ec277e/pytz-2026.6.tar.gz", hash = "sha256:3b0fc2727707663d6e517936f444fd8a8b240f1fc32f4bf7ee03274245945284", size = 318520, upload-time = "2026-10-29T09:05:47.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/25/564f3fc5b70c1398d706c51f2b1470d0ea0b1b2f96fee67877b6f5bdf3b6/pytz-2026.6-py2.py3-none-any.whl", hash = "sha256:7fed3fee8fd6bd4a2b7b75a8d56021d0624127bf69bd5691f115db929ac570b3", size = 506321, upload-time = "2026-10-29T09:05:45.14Z" }, +] + [[package]] name = "pywin32" version = "312" @@ -3986,11 +4061,11 @@ wheels = [ [[package]] name = "setuptools" -version = "84.0.0" +version = "84.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/b3/c19426f783614d625699a4b247faf628ea9ab1db228674491767b417f9e1/setuptools-84.2.1.tar.gz", hash = "sha256:3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc", size = 1230289, upload-time = "2026-10-20T19:01:37.742Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, + { url = "https://files.pythonhosted.org/packages/ef/55/6dc6d6bb953bdfc52519a0059592c52b6e3b8ad51cf8256dffceb55b0610/setuptools-84.2.1-py3-none-any.whl", hash = "sha256:ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2", size = 876263, upload-time = "2026-10-20T19:01:35.678Z" }, ] [[package]] @@ -4348,19 +4423,52 @@ wheels = [ [[package]] name = "tornado" -version = "6.5.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/61/53d562a57b28c08eda40b258c0f975e360541943ad7c7bef897a40caafda/tornado-6.5.10.tar.gz", hash = "sha256:a6b1ccd08c04b4a06fb5aeb381be99de5ad1e5375c1785e31d78c880feb57687", size = 537910, upload-time = "2026-09-15T13:47:48.73Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/5b/ff5fc58fa2427c30dea74c90053f4fc5eda1e7f3833ed3ecc7147fe2b311/tornado-6.5.10-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9261783640e23258694a9ff0795df430a5a7b0a651d3dd53dd0969ad6be16da7", size = 465883, upload-time = "2026-09-15T13:47:35.463Z" }, - { url = "https://files.pythonhosted.org/packages/ad/f5/cd7be26c34a3315532f3aef5f092465da8f59c334dd439d3c14aaef16461/tornado-6.5.10-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:83e6cf438b106c6b3852d70960967bb1b70c87438050dca0981e4b9aa751a4c1", size = 464046, upload-time = "2026-09-15T13:47:37.178Z" }, - { url = "https://files.pythonhosted.org/packages/60/33/df6d7d04854a58619f8349a51e3edb138324130a7562b0bb21f115bb940f/tornado-6.5.10-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bdf942448169e5336451d0494d7e3d81cfa726d5aa312affdc4682dd62a62f6d", size = 467096, upload-time = "2026-09-15T13:47:38.559Z" }, - { url = "https://files.pythonhosted.org/packages/29/17/cc35dff68272d685cffd8600ffafbd8067e7d05e7348d9f80caddffbbd5f/tornado-6.5.10-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69acca6501eed74582b76dbbceee2a91613f54728e3e418346000d7103101676", size = 468067, upload-time = "2026-09-15T13:47:40.085Z" }, - { url = "https://files.pythonhosted.org/packages/c3/01/6e5349b4e1a53a4b4972a6716785e1fe7407f312063c3972690af8ff301b/tornado-6.5.10-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:66aaa3f57d30c6e6becee83ff28055d5930ac724214bde99393eefda83d5e015", size = 467901, upload-time = "2026-09-15T13:47:41.576Z" }, - { url = "https://files.pythonhosted.org/packages/28/5e/b4facf94370dba006819c8d304376f8b9fbec6b935b5e51bf45823a9790b/tornado-6.5.10-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4bd192b959f9128fb99b8898148070ba4574c9589b78bce42d1851131fe85828", size = 467308, upload-time = "2026-09-15T13:47:43.145Z" }, - { url = "https://files.pythonhosted.org/packages/56/ae/047938e828cafc8eca4c908fafb6588fee944e3af39a0af9d7b602499ae5/tornado-6.5.10-cp39-abi3-win32.whl", hash = "sha256:302eb1e0e3e159314eb591920529fdea80acca92df5510a2cec5bbd4f099ec72", size = 468387, upload-time = "2026-09-15T13:47:44.556Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d4/5901517f05affd752490f6a654ba31b7474664e8dd80bd045a00c220bd88/tornado-6.5.10-cp39-abi3-win_amd64.whl", hash = "sha256:37ae8f150cecfdbf747fc4e12f5e9a97ecd8cf1d4cdb3f119e2de84b11196918", size = 468828, upload-time = "2026-09-15T13:47:45.961Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1a/fd497f3a7f7b74bb04f4b94536b5c9f80742b5d50501fd27977652ddec16/tornado-6.5.10-cp39-abi3-win_arm64.whl", hash = "sha256:ce045d3c298fddd30e89a2777f97039d1b641eb9518ac7b26a4721903539c694", size = 467847, upload-time = "2026-09-15T13:47:47.283Z" }, +version = "6.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/02/5f549b1ad5da6267ed1e1fac2eef69257b1979cc812497002499b7895ff9/tornado-6.6.tar.gz", hash = "sha256:34c4aa916d7f2b2d5cadee2c6850f5a4a7f3d688cbf58399ea9bd3057af4abd5", size = 617998, upload-time = "2026-10-21T15:31:04.733Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/f5/d3e6f76b945f9812842db742d0708a3b6287e850e81be3ca10e290c103c6/tornado-6.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:01abf15429d64f04ccfafc238b191cc2c177108b7a331f4461707ad77198c8c2", size = 528326, upload-time = "2026-10-21T15:30:05.528Z" }, + { url = "https://files.pythonhosted.org/packages/30/69/b20cf3a8bf641ec792a5a6050aa153e8f120604f9c23471142de535113e5/tornado-6.6-cp311-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:11bb97c00981eb8254e55bd6a5ad960c983ae343fef8ae3ae5584cd1fb70236a", size = 529544, upload-time = "2026-10-21T15:30:07.126Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f4/d9d8d3ac345aa9df3a132ad03bd443160c2ab24ad4003b4e8d6d5af06371/tornado-6.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e152a3b2d3eae113b836d979823f7bb2320f7e334a84449729964921f374a4c", size = 530523, upload-time = "2026-10-21T15:30:08.478Z" }, + { url = "https://files.pythonhosted.org/packages/ad/82/1296ade1c14dee5a4906e5ca092997622ece7c4dc768c68ed9caaf895d1b/tornado-6.6-cp311-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e912d4c4babd04db6f34b25b748174a4a176debe86c8610beedf1815f9af7fdd", size = 530739, upload-time = "2026-10-21T15:30:09.98Z" }, + { url = "https://files.pythonhosted.org/packages/66/30/6ac1eab66c81af46dafd93c9b24bd311f82e199d9d7b8ce2a64a91b6a200/tornado-6.6-cp311-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33aabe8a03d426cef4e0fe5669f0643cadd983b54957f1a7ac2789ec326e9644", size = 529738, upload-time = "2026-10-21T15:30:11.498Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/6c1f6aa1fd148e4d0f500a10cd0c9949e7bf385e161ff497c81de5b3ae4f/tornado-6.6-cp311-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9aaf0e1c2fb9f046f30e2036b8d2060666399e8464d0a72769e43c754089567b", size = 527908, upload-time = "2026-10-21T15:30:13.298Z" }, + { url = "https://files.pythonhosted.org/packages/54/5b/67d3d452b1179bc9e265cebd381a56ab4cf2d7b2a54607aedd8d6e1ebc2e/tornado-6.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61fc5513cea97720ab9bf54b04827ac0f4500c63f0b072b963d6145c798b4661", size = 530367, upload-time = "2026-10-21T15:30:15.183Z" }, + { url = "https://files.pythonhosted.org/packages/74/14/4e8e9a14f77bcafb2408634b2d4c8c676ad7a43bf8dac4ee2d48370698f9/tornado-6.6-cp311-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:31e1a0d5cd704d2fe8a008c1cc27a0e2b67269ca263630a27985dddc160b5537", size = 530456, upload-time = "2026-10-21T15:30:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/56/12/e3ddef99a2089ccfc86f32bd3f39caaad87bee4868be1bacbf213a0b07a2/tornado-6.6-cp311-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:74416e41e9bdf70fbc2fb242f6fbbc7a58ec11e59d39f15fc5a421ecb6845118", size = 529610, upload-time = "2026-10-21T15:30:18.031Z" }, + { url = "https://files.pythonhosted.org/packages/35/3d/62a692343d46b37e852e5078e0f6513534d925f70a04580072e3c127b129/tornado-6.6-cp311-abi3-musllinux_1_2_s390x.whl", hash = "sha256:8cdd69104ae9d705b3f04d2563aa96e357043e267a455802daae9a1e633a37c8", size = 529803, upload-time = "2026-10-21T15:30:19.326Z" }, + { url = "https://files.pythonhosted.org/packages/49/04/6c59a9413adfe4810f648a4dd9fdf476f13b0495f9cc25ef856b71f9ce1e/tornado-6.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fc7c8a3263501fbe97518cd699101bc3c5f499a7dd4f75fc6cbb3bd9ee1db1f1", size = 529751, upload-time = "2026-10-21T15:30:20.826Z" }, + { url = "https://files.pythonhosted.org/packages/31/36/a1b3b84cd347ae43f982d3bf95628de6c6365668dba2543af47c6a1c888e/tornado-6.6-cp311-abi3-win32.whl", hash = "sha256:b54ca69ff93d0374efb083a12a1e9ec2dd2036e8496d197e0e8ef77b8d9135c3", size = 530643, upload-time = "2026-10-21T15:30:22.428Z" }, + { url = "https://files.pythonhosted.org/packages/b7/50/dc813fe35a672e14ee8bed8b5c634eeb343a05c98eb455318a169de24567/tornado-6.6-cp311-abi3-win_amd64.whl", hash = "sha256:48f26ed9d7beb64e5f06adc864524e581ab6d62ba8ff46eaaa1ac2d0e6d753fd", size = 530886, upload-time = "2026-10-21T15:30:23.975Z" }, + { url = "https://files.pythonhosted.org/packages/0d/71/62a9b9e980026d38874740d0a7c162f53eb0563f6a441e3aff84c48db7e8/tornado-6.6-cp311-abi3-win_arm64.whl", hash = "sha256:29114ffd4e4de869d390393df9e9a0f191065aae1a96f38a58d35e464c04a867", size = 530899, upload-time = "2026-10-21T15:30:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4c/633e3568eb574df555d6ddd2dac44b7793a5c26cd63b444e653fcfb1d6ec/tornado-6.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:63fc8d243b52fd8dd9ed4ad02d0df08f2024f9aea632a433a8a6c91075076d44", size = 528432, upload-time = "2026-10-21T15:30:26.578Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/b525d1870ad28a56f47b664dd8fd3fab4f7ddd237c87c14e97248bf4762f/tornado-6.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d4b85cac795bfc6a7f20ba81871abb8c2ae8fba019061b8a9ae1c2f97e29637", size = 532586, upload-time = "2026-10-21T15:30:28.027Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/909a33c253352346ca081a27ca66b45c1398468adf784b767597ca773c28/tornado-6.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e035c065edadfa696529d05eebcbeb25154c129b5aee6dbfb697a6abf5d4f697", size = 533641, upload-time = "2026-10-21T15:30:29.318Z" }, + { url = "https://files.pythonhosted.org/packages/6f/64/100202666d6e3d96288ab184827762415456c139798a15c962547f9fabf9/tornado-6.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b94e589ab855090c448b5c0937b3a75f1c1a604b7b36207e8236d209f720b9c", size = 533869, upload-time = "2026-10-21T15:30:30.882Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e0/af3ed62bc15830d60328d5b4a1a9076b35ea0ba142cb17b1c1772cb15c59/tornado-6.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a394c8c9db9137f2a2b42869d9f7b91398fcf19da120d0dde4c4546172710f7", size = 532767, upload-time = "2026-10-21T15:30:32.273Z" }, + { url = "https://files.pythonhosted.org/packages/24/d7/1e2e1a6932eb62d63324b64dba2f62e6eaf79ad883dd209352c297b7848c/tornado-6.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0215d1df6e4b5e736ddad5adf0af4138cf7437fdd39c77c5e69768e37850a632", size = 530956, upload-time = "2026-10-21T15:30:33.746Z" }, + { url = "https://files.pythonhosted.org/packages/70/65/3b9a0e53d7a4c39f2ebe95486540fa8eeb67bfb6e8c437d603c00d35b128/tornado-6.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4d711790e0a9576ad924d942c4607e4e401d697588ea6f30f6662824008a7add", size = 533372, upload-time = "2026-10-21T15:30:35.085Z" }, + { url = "https://files.pythonhosted.org/packages/44/48/90979cbfaa3ff941324a2605eb794af0f087c670cd99f18a69ab40498073/tornado-6.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:034a66bae0f55421d007803e2385d2a59707d8829b2f026373d42f08256e9922", size = 533425, upload-time = "2026-10-21T15:30:36.425Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c4/ce743a86d91f85c255b77a4282eb5b2716b61e74430eb9d1df8578a539d2/tornado-6.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5cb38483b077129056a94dd415a71d29ab4e42b46da894fee8b4657003f10b21", size = 532545, upload-time = "2026-10-21T15:30:37.789Z" }, + { url = "https://files.pythonhosted.org/packages/02/5e/a82131e35778540fc19139229d02aa8cf2453a8022e202be50c88f0f8ac3/tornado-6.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:46bbbd107efa37777ac4b83601866d19b4dbd5b770a7a9123582633d95959ad3", size = 532700, upload-time = "2026-10-21T15:30:39.336Z" }, + { url = "https://files.pythonhosted.org/packages/3a/09/6dac8c2c677c65d60b3d7dae94176dbfe392f314e7c8e2d709623edd6a26/tornado-6.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b85e9d0263cdbc027b95947c8b873a7bc7fd6a08d16bfb4f5047350b0808e464", size = 532712, upload-time = "2026-10-21T15:30:40.698Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ac/71e5776cbcdc3264e81aa6db28c69717ba50607a9eb8d5b766f11a4b020e/tornado-6.6-cp314-cp314t-win32.whl", hash = "sha256:4276eef0880e5fe4488e6799c2842f59af3b06637e9313ab4fb506f9c9c2d6cb", size = 530491, upload-time = "2026-10-21T15:30:42.115Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0f/0b6a6908a5b5aa8320009dfdb84434b1cf160b577a00456e59ab9e6ea598/tornado-6.6-cp314-cp314t-win_amd64.whl", hash = "sha256:10d4af98c6df09f8429b1340ccaea3a72887b86f695dfb8056bda517c8115a00", size = 530780, upload-time = "2026-10-21T15:30:43.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/39/c152e1cc5371f75f5f9f0a4f44bf2dc9396be9407d384de7428446fb4651/tornado-6.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b1f0ef20a5278854f5fdac31637bafadb055405df28b7fc945e54add12b172e3", size = 530781, upload-time = "2026-10-21T15:30:44.741Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c5/f96c7d7a8895745bb37dfb6e6107e1ee40ad02a4b162d7d6c143644455ab/tornado-6.6-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8f8e382da30fe042f27f27a871e21aefc2613870e5a41648de18446c72d6ea59", size = 528446, upload-time = "2026-10-21T15:30:46.112Z" }, + { url = "https://files.pythonhosted.org/packages/c0/dd/ab8f6500d274b9c55b025798eb54e92591bcff27515799bec443bd54e35a/tornado-6.6-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8c7a8a328396c2195454a2ecd9499512441bbce99505544a717e6a87d16ff6f9", size = 532774, upload-time = "2026-10-21T15:30:47.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/23/b78e7d1bf329160b8157284005395421ea941864c16c7f04c3e48c98bcf9/tornado-6.6-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca025bb0da81c294aef26f27427316df1525220820315f4bf32dc1e84a4d2232", size = 533872, upload-time = "2026-10-21T15:30:48.884Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4f/ab66b614ad0bc9a219484be6bc2c002c8de9916a7b6c7b27fe6273838e5c/tornado-6.6-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:59c22ebc905ac2a267f8d9d8a878f86afbcbbde329d75c485f1422fc45a81b83", size = 534027, upload-time = "2026-10-21T15:30:50.157Z" }, + { url = "https://files.pythonhosted.org/packages/da/63/501fa15a36bace2f4b9158228cf922d606403261bf71c960c433ec3ca0a4/tornado-6.6-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f89802bc0bcd15f988d9325b4e6d2171bb04ea68d188732556335e10d3fd00bd", size = 532936, upload-time = "2026-10-21T15:30:51.45Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b7/82d643f635f0300bda98613ca2660b4f3134b6fb594c6fddfa4de706806c/tornado-6.6-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:70043256d5958f9c655066153d23d60ea3e29bf9854f7fb75850e223c477758a", size = 531254, upload-time = "2026-10-21T15:30:52.865Z" }, + { url = "https://files.pythonhosted.org/packages/d4/53/92c40891a94750bc09257310af964bf25c59e3531d6199736c318baa864f/tornado-6.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:82b49495e3849d644b841ef33b35491160055997deaa93720ff647ef019421db", size = 533566, upload-time = "2026-10-21T15:30:54.15Z" }, + { url = "https://files.pythonhosted.org/packages/7e/88/b82f5c5efde731b88d839ad99cb6ace2a521f03bb6d7346d2de089c76da8/tornado-6.6-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:07b2bcaa4bf447bdbfb664e4c28344226540db3f0ebccd74886528338a240765", size = 533567, upload-time = "2026-10-21T15:30:55.451Z" }, + { url = "https://files.pythonhosted.org/packages/02/2c/e9a7f5d0fca0bdbcd48b2d9c7884dd0b32669c9945d98bc6d303a80ecfea/tornado-6.6-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:c36b921374c302e54909bae17d9f354061d80e7ae7d8c94f73abbcc217866460", size = 532849, upload-time = "2026-10-21T15:30:56.729Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f5/4ade737340ca6ce9c677ea0c2843591c8ad065d07f0913c28d922c7e078e/tornado-6.6-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:7412d9e37aa9f7048e4b591f520a95f7a097278ffab15f1ddf3f5c2320cf6648", size = 532873, upload-time = "2026-10-21T15:30:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/8e/04/6acdc937d0bb6189ddb34ceded517d7bd1c7b3bded5d4e1b3442160e6b6f/tornado-6.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:15bc95112b2d24b0bfaee9a004e3f974da929e33b4244a7f695a22f45b08a5e5", size = 532883, upload-time = "2026-10-21T15:30:59.395Z" }, + { url = "https://files.pythonhosted.org/packages/ab/27/0a766fac6b1cc629d96346329a0485287fd2857c51a887a3a154f2a69938/tornado-6.6-cp315-cp315t-win32.whl", hash = "sha256:f661c21117b875d612371f3eb243d0c69f56d9bdd6aee38b7c24453419dfe554", size = 530488, upload-time = "2026-10-21T15:31:00.77Z" }, + { url = "https://files.pythonhosted.org/packages/5c/80/3e4142570883da27b3f60ae82c638ac96e60997eac684740613557519c96/tornado-6.6-cp315-cp315t-win_amd64.whl", hash = "sha256:ee8853ab35da510dc952879ebd9995c82bc10ffb7925fd87930dc0d6916f4b39", size = 530777, upload-time = "2026-10-21T15:31:02.087Z" }, + { url = "https://files.pythonhosted.org/packages/17/57/4a85c617b6349962320b4afbe1386d5b05fd57218ee8ad6e82e0110ec9ee/tornado-6.6-cp315-cp315t-win_arm64.whl", hash = "sha256:7de62e2dfdf559fd7154d2b9aa58ff3d9cff8304ae0519341feb082618bc2a33", size = 530780, upload-time = "2026-10-21T15:31:03.425Z" }, ] [[package]]