Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,29 @@ on:
jobs:
label-gate:
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/label-gate.yml@main
build-test:
build-test-sqlite:
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/build-test.yml@main
build-test-postgres:
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/build-test-postgres-v2.yml@main
with:
postgres-db: omop_graph_test
setup-commands: |
uv run omop-config configure omop_graph \
--set cdm_db.kind=cdm \
--set cdm_db.connection.dialect=postgresql+psycopg \
--set cdm_db.connection.host=localhost \
--set cdm_db.connection.port=5432 \
--set cdm_db.connection.user=test \
--set cdm_db.connection.password=test \
--set cdm_db.connection.database_name=omop_graph_ci_placeholder \
--set cdm_db.connection.test_only=false \
--set cdm_db.schema_name=public \
--set test_cdm_db_pg.kind=cdm \
--set test_cdm_db_pg.connection.dialect=postgresql+psycopg \
--set test_cdm_db_pg.connection.host=localhost \
--set test_cdm_db_pg.connection.port=5432 \
--set test_cdm_db_pg.connection.user=test \
--set test_cdm_db_pg.connection.password=test \
--set test_cdm_db_pg.connection.database_name=omop_graph_test \
--set test_cdm_db_pg.connection.test_only=true \
--set test_cdm_db_pg.schema_name=public
3 changes: 0 additions & 3 deletions Dockerfile

This file was deleted.

4 changes: 2 additions & 2 deletions docs/oaklib/interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ The primary adapter class that inherits from multiple OAK interfaces:
### Resource Management
To initialize a connection, `omop-graph` uses a specialized resource factory:

* **`OMOPOntologyResource`**: A dataclass that wraps the SQLAlchemy connection URL, treating the database as a live ontology source.
* **`omop_resource()`**: A factory function that resolves database credentials from an explicit URL, or from the active oa-configurator stack config (`OmopGraphConfig.cdm_db`) when no URL is given.
* **`OMOPOntologyResource`**: A dataclass that wraps the SQLAlchemy connection URL, treating the database as a live ontology source. When the resolved CDM database has a genuinely separate `vocab_connection` configured, it also carries a second URL (`vocab_url`) for the vocabulary server.
* **`omop_resource()`**: A factory function that resolves database credentials from an explicit URL, or from the active oa-configurator stack config (`OmopGraphConfig.cdm_db`) when no URL is given. Populates `vocab_url` automatically from the resolved config's `vocab_connection`, when configured; an explicit `url=` has no vocabulary split to carry.

---

Expand Down
12 changes: 6 additions & 6 deletions docs/reasoning/grounding.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ To accelerate the grounding to standard concepts, `omop-graph` makes use of:
| `parent_ids` | `tuple[int, ...]` | `None` | Only accept candidates that are descendants of these OMOP concept IDs (hierarchy validation via `concept_ancestor`). |
| `search_constraint` | `ConceptFilter` | `None` | Filters applied to the initial resolver query (concept IDs, domain, vocabulary, standard/active flags, and limit). |
| `max_depth` | `int` | `6` | Maximum hop distance allowed between a candidate and its standard anchor. |
| `predicate_kinds` | `frozenset[PredicateKind]` | `{IDENTITY}` | Relationship kinds followed when walking from a non-standard candidate to its standard anchor. |
| `predicate_kinds` | `frozenset[PredicateKind]` | `{IDENTITY}` | Relationship kinds followed when walking from a non-standard candidate to its standard anchor. Currently locked: `__post_init__` raises `ValueError` for any value other than exactly `frozenset({PredicateKind.IDENTITY})` — not yet configurable in practice, despite being a normal dataclass field. |

### ConceptFilter

Expand Down Expand Up @@ -99,15 +99,15 @@ TotalScore = Relevance - ParsimonyPenalty + BroadnessBonus
$$

#### 1. Relevance
Relevance represents the initial semantic fit and is computed as **either** embedding similarity **or** textual similarity — not both simultaneously:
Relevance represents the initial semantic fit and is computed as **either** embedding similarity **or** textual similarity, chosen per candidate rather than globally — not both simultaneously for the same candidate:

- **Without embeddings**: textual similarity is used exclusively.
- **With embeddings** (default when `omop-graph[emb]` is installed and configured): embedding cosine similarity **replaces** the textual score entirely.
- A candidate resolved by `EmbeddingResolver` (`match_kind == LabelMatchKind.EMBEDDING`) gets embedding cosine similarity.
- Every other candidate — resolved via exact/partial/full-text matching — always gets textual similarity, whether or not `omop-graph[emb]` is installed.

The two scoring modes:

- **Embedding Similarity**: Cosine similarity between the input text embedding and the concept embedding. Requires `omop-graph[emb]` and a configured `KnowledgeGraphEmbeddingConfiguration` — see the [Knowledge Graph docs](../graph/kg.md#embedding-configuration) and the [omop-emb documentation](https://australiancancerdatanetwork.github.io/omop-emb/) for setup.
- **Textual Similarity**: A custom token-overlap score that heavily penalizes missing words from the user's query but allows for "extra" descriptive words in the OMOP concept name. Used as a fallback when no embedding is available.
- **Embedding Similarity**: Cosine similarity between the input text embedding and the concept embedding. Only applies to candidates resolved via `EmbeddingResolver`, which requires `omop-graph[emb]` and a configured `KnowledgeGraphEmbeddingConfiguration` — see the [Knowledge Graph docs](../graph/kg.md#embedding-configuration), [Resolver Pipelines](resolvers.md), and the [omop-emb documentation](https://australiancancerdatanetwork.github.io/omop-emb/) for setup.
- **Textual Similarity**: A custom token-overlap score that heavily penalizes missing words from the user's query but allows for "extra" descriptive words in the OMOP concept name. Used for every candidate not resolved via embeddings.

#### 2. Parsimony: Distance Penalty
OMOP is a deep hierarchy. A concept that is 1 hop away from your search term is more likely to be correct than one found 5 hops away.
Expand Down
7 changes: 4 additions & 3 deletions docs/reasoning/resolvers.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ The backbone of the `ResolverPipeline` are specific **resolvers**. `omop-graph`

- **`ExactLabelResolver`**: Exact case-insensitive match. Is there anywhere in the the [`concept`](https://ohdsi.github.io/CommonDataModel/cdm54.html#concept) table the exact string `"Hodgkin lymphoma"`
- **`ExactSynonymResolver`**: Similar to `ExactLabelResolver` yet searching the [`concept_synonym`](https://ohdsi.github.io/CommonDataModel/cdm54.html#concept_synonym) table
- **`FullTextResolver`**: Matches irrespective of word order. Not as relevant for the example above but relevant for others (e.g., "Kidney Cancer" -> "Cancer of Kidney").
- **`FullTextSynonymResolver`**: Similar to `FullTextResolver` yet searching the [`concept_synonym`](https://ohdsi.github.io/CommonDataModel/cdm54.html#concept_synonym) table
- **`PartialLabelResolver`**: Substring match. Is the search string `"Hodgkin lymphoma"` a partial component of any [`concept`](https://ohdsi.github.io/CommonDataModel/cdm54.html#concept)?
- **`PartialSynonymResolver`**: Similar to `PartialLabelResolver` yet searching the [`concept_synonym`](https://ohdsi.github.io/CommonDataModel/cdm54.html#concept_synonym) table
- **`FullTextResolver`**: Matches irrespective of word order. Not as relevant for the example above but relevant for others (e.g., "Kidney Cancer" -> "Cancer of Kidney").
- **`FullTextSynonymResolver`**: Similar to `FullTextResolver` yet searching the [`concept_synonym`](https://ohdsi.github.io/CommonDataModel/cdm54.html#concept_synonym) table
- **`EmbeddingResolver`**: Vector-similarity match, appended to the pipeline only when `omop-graph[emb]` (`omop-emb`) is installed.

!!! tip

Traversing each of the resolvers one by one can be an exhaustive search. The `ResolverPipeline` therefore offers a `stop_after_resolver` option. If set, retrieval from the DB stops after that resolver has concluded. The resolvers are ordered based on their confidence as above (i.e. **`ExactLabelResolver`** >> **`ExactSynonymResolver`** >> etc.)
Traversing each of the resolvers one by one can be an exhaustive search. The `ResolverPipeline` therefore offers a `stop_after_resolver` option. If set, retrieval from the DB stops after that resolver has concluded. `ALL_RESOLVERS`, the default sequence, is ordered exactly as listed above (i.e. **`ExactLabelResolver`** >> **`ExactSynonymResolver`** >> **`PartialLabelResolver`** >> ... >> **`EmbeddingResolver`**).

```python
from omop_alchemy.cdm.query import ConceptFilter
Expand Down
4 changes: 2 additions & 2 deletions docs/usage/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,5 @@ omop-graph relationship-classification --pred-class-dir <PATH_TO_CSV_DIR>

| Option | Short | Type | Default | Description |
| :--- | :--- | :--- | :--- | :--- |
| **`--pred-class-dir`** | | `String` | **Required** | Path to the directory containing the classification CSVs. |
| **`--verbose`** | `-v` | `Count` | `0` | Increase logging verbosity (use `-v` or `-vv`). |
| **`--pred-class-dir`** | | `String` | `None` (bundled CSVs) | Path to a directory of classification CSVs, overriding the bundled defaults. |
| **`--verbose`**{: title="Global option, not specific to this subcommand — see the note above." } | `-v` | `Count` | `0` | Increase logging verbosity (use `-v` or `-vv`). Global option; must precede the subcommand name (see note above). |
18 changes: 17 additions & 1 deletion docs/usage/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,30 @@ The full suite runs against an **in-memory SQLite mock CDM** (`tests/fixtures/mo

The grounding test suite is structured with parametrized cases so each clinical term is a separate pytest case for easier isolation and debugging.

### PostgreSQL-only integration suite

A separate suite, tagged with the `db_dialect` marker and excluded from the
default run (`pytest.toml`'s `-m "not db_dialect"`), runs against a real
PostgreSQL database via oa-configurator's test infrastructure. This is where
schema-drift protection and split-connection behavior are covered:
`test_schema_provenance_guard.py`, `test_vocab_split_connection.py`,
`test_oaklib_schema_awareness.py`, `test_fulltext_vocab_schema_postgres.py`,
`test_predicate_flags.py`, `test_relationship_classification.py`.

## Running Tests

Run all tests:
Run all tests (SQLite suite only, the default):

```bash
pytest
```

Include the PostgreSQL-only suite:

```bash
pytest -m db_dialect
```

Run one file:

```bash
Expand Down
2 changes: 1 addition & 1 deletion pytest.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[pytest]
testpaths = ["tests"]
addopts = ["-rf", "-rx", "--disable-pytest-warnings"]
addopts = ["-rf", "-rx", "--disable-pytest-warnings", "-m", "not db_dialect"]
log_cli = true
log_cli_level = "DEBUG"
log_cli_format = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
Expand Down
135 changes: 100 additions & 35 deletions src/omop_graph/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,22 @@
import typer
from sqlalchemy.orm import sessionmaker

from orm_loader.backends import resolve_backend
from oa_configurator import (
ResolvedCDMDatabase,
Role,
ensure_schema,
guard_schema_provenance_for,
open_connection,
physical_schema_of
)

from orm_loader.backends import STAGING_SCHEMA, resolve_backend
from orm_loader.helpers import bulk_load_context
from orm_loader.helpers.metadata import Base
from orm_loader.loaders.loader_interface import PandasLoader

from omop_graph.config import OmopGraphConfig
from omop_graph.db.session import make_engine
from omop_graph.db.session import resolve_cdm_database
from omop_graph.extensions.omop_alchemy import RelationshipClass, RelationshipMapping
from omop_graph.cli_utils import populate_test_data

Expand All @@ -41,9 +50,11 @@ def _main(
@app.command()
def populate_with_test_data():
"""Populate the database with synthetic test data."""
engine = make_engine()
resolved = resolve_cdm_database()
engine, vocab_engine = resolved.create_engines()
Session = sessionmaker(bind=engine, future=True)
populate_test_data(Session())
VocabSession = sessionmaker(bind=vocab_engine, future=True)
populate_test_data(Session(), vocab_session=VocabSession())


def packaged_predicate_csv_dir() -> Path:
Expand All @@ -57,20 +68,29 @@ def packaged_predicate_csv_dir() -> Path:
return Path(str(resources.files("omop_graph") / "data"))


@app.command()
def relationship_classification(
pred_class_dir: Annotated[
Optional[str],
typer.Option(
help=(
"Path to the directory containing `predicate_classification.csv` "
"and `predicate_mapping.csv`. Defaults to the copies shipped with "
"omop-graph; pass a directory to override them."
)
),
] = None,
):
"""Load pre-classified predicates into the database."""
pred_class_dir: Optional[str] = None,
*,
engine: sa.Engine | sa.Connection | None = None,
resolved: ResolvedCDMDatabase | None = None,
) -> None:
"""Load pre-classified predicates into the database.

Parameters
----------
pred_class_dir : str, optional
Path to the directory containing `predicate_classification.csv` and
`predicate_mapping.csv`. Defaults to the copies shipped with
omop-graph.
engine : sqlalchemy.Engine or sqlalchemy.Connection, optional
Bindable to run against. Defaults to the active oa-configurator
config's resolved CDM engine, in which case resolved is also
resolved internally and any value passed here is ignored.
resolved : ResolvedCDMDatabase, optional
Enables the schema-provenance guard. Only meaningful together with
an explicitly injected engine/connection, since the engine=None
path always resolves its own regardless of what's passed here.
"""
pred_class_dir_pl = (
Path(pred_class_dir) if pred_class_dir else packaged_predicate_csv_dir()
)
Expand Down Expand Up @@ -139,32 +159,59 @@ def relationship_classification(
subset=["relationship_id", "predicate_kind", "predicate_subkind"]
)

engine = make_engine()
if engine is None:
resolved = resolve_cdm_database()
engine = resolved.create_engine()
if resolved is not None and resolved.connection != resolved.vocab_connection:
raise RuntimeError(
f"relationship_classification() cannot run against database "
f"{resolved.name!r}: its vocab_connection is a genuinely separate "
"connection from the primary one, and RelationshipMapping's FK to "
"relationship.relationship_id needs both in the same database. "
"Point vocab_connection at the same connection as primary for "
"this command, or provision relationship_class/relationship_mapping "
"manually without the FK constraint."
)
db_schema = physical_schema_of(engine)
ensure_schema(engine, db_schema)
ensure_schema(engine, STAGING_SCHEMA)

Session = sessionmaker(bind=engine, future=True)
session = Session()
loader_backend = resolve_backend(engine)
loader_backend = resolve_backend(engine, staging_schema=STAGING_SCHEMA)

with engine.begin() as conn:
conn.execute(
sa.text(
"DROP TABLE IF EXISTS "
f"{loader_backend.qualified_staging_name(RelationshipMapping.__tablename__)} CASCADE"
)
)
conn.execute(
sa.text(
"DROP TABLE IF EXISTS "
f"{loader_backend.qualified_staging_name(RelationshipClass.__tablename__)} CASCADE"
)
)
conn.execute(sa.text("DROP TYPE IF EXISTS predicatekindenum CASCADE;"))
drop_staging_sql = (
sa.text(
"DROP TABLE IF EXISTS "
f"{loader_backend.qualified_staging_name(RelationshipMapping.__tablename__)} CASCADE"
),
sa.text(
"DROP TABLE IF EXISTS "
f"{loader_backend.qualified_staging_name(RelationshipClass.__tablename__)} CASCADE"
),
)
with open_connection(engine) as connection:
for stmt in drop_staging_sql:
connection.execute(stmt)

# DROP TYPE IF EXISTS predicatekindenum was dead code: the Enum column
# never set an explicit name=, so SQLAlchemy's generated type name is
# actually "predicatekind". drop_all(tables=[...]) already drops the
# shared Enum type exactly once, deduped, since both tables using it
# are always in the same tables= list.
tables_to_drop = [
RelationshipMapping.__table__,
RelationshipClass.__table__,
]
Base.metadata.drop_all(bind=engine, tables=tables_to_drop, checkfirst=True) # type: ignore
Base.metadata.create_all(bind=engine, tables=tables_to_drop) # type: ignore
# Both tables live in the primary schema (only RelationshipMapping's FK
# target is vocab-tagged, via role_fk), so the guard checks Role.PRIMARY.
with open_connection(engine) as connection:
guard = guard_schema_provenance_for(
connection, resolved, schema_tag=Role.PRIMARY, tables=tables_to_drop # ty: ignore[invalid-argument-type]
)
with guard:
Base.metadata.drop_all(bind=connection, tables=tables_to_drop, checkfirst=True) # type: ignore
Base.metadata.create_all(bind=connection, tables=tables_to_drop) # type: ignore

with tempfile.TemporaryDirectory() as tmp_dir:
for model, df in zip(
Expand All @@ -184,9 +231,27 @@ def relationship_classification(
dedupe=True,
merge_strategy="replace",
loader=PandasLoader(),
staging_schema=STAGING_SCHEMA,
)
session.commit()


@app.command(name="relationship-classification")
def relationship_classification_cmd(
pred_class_dir: Annotated[
Optional[str],
typer.Option(
help=(
"Path to the directory containing `predicate_classification.csv` "
"and `predicate_mapping.csv`. Defaults to the copies shipped with "
"omop-graph; pass a directory to override them."
)
),
] = None,
):
"""Load pre-classified predicates into the database."""
relationship_classification(pred_class_dir)


if __name__ == "__main__":
app()
Loading
Loading