Skip to content
312 changes: 156 additions & 156 deletions CHANGELOG.md

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Shortcut:
make setup
```

## Daily edit -> check loop
## Daily edit/check loop

```bash
# after changing Rust code, or when the extension may be stale
Expand Down Expand Up @@ -102,7 +102,7 @@ What the gates cover:

- **Ruff linting and formatting.** The current tree enforces core pycodestyle/pyflakes safety checks plus stale-suppression detection. It also enforces an expanded rule set covering common bug patterns (bugbear), simplifications, Python-version upgrades, pytest style, import order, and comprehensions. Treat `uv run ruff check .` and `uv run ruff format --check .` as the stable interface rather than relying on individual rule codes.
- **Pyright.** Type checking runs through `uv run pyright`; the project is tightening this as a strict-inference ratchet over time.
- **Python tests.** The suite is offline and runs in parallel by default via [pytest-xdist](https://pypi.org/project/pytest-xdist/) (`-n auto` in `pyproject.toml`'s addopts, which also enable `--cov` so coverage is reported inline); expect roughly 20-30 seconds for a full local run. Disable parallelism for a single serial run with `pytest -n 0`. CI installs the `ci` dependency group with the `cli`, `qc`, and `log` extras (`--no-default-groups --group ci --extra cli --extra qc --extra log`) and runs the suite as a single job rather than sharding it across a matrix of runners: most of a run is fixed overhead (interpreter start, imports, xdist worker spin-up, coverage init) rather than test execution, so splitting the suite would cost more in per-runner setup than it recovers.
- **Python tests.** The suite is offline and runs in parallel by default via [pytest-xdist](https://pypi.org/project/pytest-xdist/) (`-n auto` in `pyproject.toml`'s addopts, which also enable `--cov` so coverage is reported inline); Disable parallelism for a single serial run with `pytest -n 0`. CI installs the `ci` dependency group with the `cli`, `qc`, and `log` extras (`--no-default-groups --group ci --extra cli --extra qc --extra log`) and runs the suite as a single job rather than sharding it across a matrix of runners: most of a run is fixed overhead (interpreter start, imports, xdist worker spin-up, coverage init) rather than test execution, so splitting the suite would cost more in per-runner setup than it recovers.
- **Rust tests.** `cargo test --manifest-path rust/Cargo.toml` runs the extension's Rust unit tests.
- **Rust style and lints.** `cargo fmt --check` enforces formatting; clippy runs all targets with warnings denied.

Expand Down Expand Up @@ -130,7 +130,7 @@ On every **push**, the whole-repo gates:

The ruff hooks cover the **whole tree**, matching CI's `ruff check .`. They used to be scoped to `src/` and `tests/`, which meant `examples/` could only ever fail in CI.

The full pytest suite and `cargo test` are deliberately in neither stage: they rebuild the Rust extension, which is too slow and too stateful for a commit/push hook, and CI already runs them on every pull request -- the Python suite as a single job, deliberately not sharded across a matrix. Use `make check` when you want everything locally.
The full pytest suite and `cargo test` are deliberately in neither stage: they rebuild the Rust extension, which is too slow and too stateful for a commit/push hook, and CI already runs them on every pull request (the Python suite as a single job, deliberately not sharded across a matrix). Use `make check` when you want everything locally.

## Running subsets

Expand Down Expand Up @@ -166,6 +166,7 @@ If your OS hard limit is lower, raise the system/user NOFILE limit first, then o
- Keep PRs focused on one concern.
- Include tests or explain why none are needed.
- Run `make check` and include the result in the PR description.
- For documentation changes, also run the docs guards (`uv run pytest tests/test_docs_source_of_truth.py tests/test_docs_cli_coverage.py tests/test_docs_examples.py`) and `uv run mkdocs build --strict`; doc pages are bound to live models, CLI flags, and output by those tests.
- PRs are squash-merged to `main`; write commits and PR titles so the squashed history stays clear.

## Getting help
Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,15 @@ Build the knowledge graph:
tablassert build-kg graph.yaml
```

Output is one JSON object per line: nodes with Biolink categories, edges with annotations.
Output is one JSON object per line: nodes with Biolink categories, edges with annotations. The edge sample below is abridged; real records also carry provenance fields such as `knowledge_level`, `agent_type`, `sources`, and `original_subject`/`original_object`.

```json
{"id":"HGNC:11998","name":"TP53","category":["biolink:Gene"],"taxon":"NCBITaxon:9606"}
{"id":"MONDO:0008903","name":"lung cancer","category":["biolink:Disease"]}
{"id":"HGNC:11998","name":"TP53","category":["biolink:Gene"],"in_taxon":["NCBITaxon:9606"],"provided_by":["infores:my-kg"]}
{"id":"MONDO:0008903","name":"lung cancer","category":["biolink:Disease"],"in_taxon":["NCBITaxon:9606"],"provided_by":["infores:my-kg"]}
```

```json
{"id":"2cfea591-0f8f-33af-a7df-03da531d3359","subject":"HGNC:11998","predicate":"biolink:associated_with","object":"MONDO:0008903","p_value":"1.0000e-03","statistical_significance_qualifier":"strongly_significant","has_supporting_studies":{"PMID:12345678":{"id":"PMID:12345678","name":"gene-disease.csv","study_size":450,"has_study_results":[{"id":"row:2"}]}},"publications":["PMID:12345678"]}
{"id":"8f41c2d7-9b3e-35c1-a4e8-6d2f9a0c5b71","subject":"HGNC:11998","predicate":"biolink:associated_with","object":"MONDO:0008903","p_value":"1.0000e-03","statistical_significance_qualifier":"very_strongly_significant","has_supporting_studies":{"PMID:12345678":{"id":"PMID:12345678","name":"gene-disease.yaml SECTION 1","study_size":450,"has_study_results":[{"id":"row:2"}]}},"publications":["PMID:12345678"]}
```

See the [Tutorial](https://skyeav.github.io/Tablassert/tutorial/) for the full walkthrough.
Expand All @@ -140,8 +140,10 @@ See the [Tutorial](https://skyeav.github.io/Tablassert/tutorial/) for the full w
pip install tablassert
```

Or with uv: `uv tool install "tablassert[cli]"`. The base install provides the Python API; `[cli]`
provides the `tablassert` command. Other extras are opt-in:
To use the Python API, install Tablassert into your project's environment with `pip install
"tablassert"` (or `uv add tablassert`). To get the `tablassert` command as a standalone tool, use
`uv tool install "tablassert[cli]"`; note that a tool install is isolated and does not expose the
Python API to your project. Other extras are opt-in:

| Extra | Adds | Install |
| ----- | ---- | ------- |
Expand Down
23 changes: 17 additions & 6 deletions docs/api/fullmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,11 @@ Path to the fullmap redb file (already resolved; see `fullmap_db_path()` and [Fu

**`taxon: Optional[str]`**

Optional NCBI Taxon ID for filtering results.
Optional NCBI Taxon ID for filtering results. The constraint applies to matches that carry taxon
metadata; matches with `TAXON_ID` 0 (no taxon recorded) are always retained.

Example: `"9606"` filters to human-specific entities.
Example: `"9606"` keeps human entries and entries with no taxon recorded, and drops entries recorded to
another taxon.

**`prioritize: Optional[list[Categories]]`**

Expand Down Expand Up @@ -142,7 +144,12 @@ db = Path("/path/to/fullmap/data/fullmap.redb")
# LazyFrame with data to resolve
lf = pl.scan_parquet("data.parquet")

# Resolve gene symbols to CURIEs
# Resolve gene symbols to CURIEs. level_one and level_two must already have been
# applied, so the frame carries both "gene_symbol" and "gene_symbol_two".
from tablassert.nlp import level_one, level_two

lf = level_one(lf, "gene_symbol")
lf = level_two(lf, "gene_symbol")
result = resolve(
lf=lf,
col="gene_symbol",
Expand Down Expand Up @@ -206,7 +213,10 @@ Rows without a valid CURIE are filtered from the returned frame.

### Provenance Tracking

Every resolved entity carries its source database, source version (snapshot date), and the matched synonym that triggered the match, enabling auditing and quality control. Case is handled by the NLP levels above: `level_one` matches any case variant, `level_two` further strips punctuation for hyphenated or slash-delimited names.
Every resolved entity carries its source database (`SOURCE_NAME`), source version (`SOURCE_VERSION`, the
snapshot date), and the normalized term that produced the match, enabling auditing and quality control.
Case is handled by the NLP levels above: `level_one` matches any case variant, `level_two` further
strips punctuation for hyphenated or slash-delimited names.

## Integration with QC

Expand Down Expand Up @@ -297,8 +307,9 @@ for term, matches in quick_map(["TP53", "BRCA1", "nonsense"], db).items():

- `column_context` is fixed `False`: the frequency tiebreaker counts category occurrences within
one resolved column of a real table, and a handful of probe terms is not that population.
- A term whose keys were all dropped by the junk-term filter (purely numeric or sentinel values)
resolves to the empty frame, because a build would never probe it either.
- A term whose keys were all dropped by the junk-term filter (an empty string, an all-digit string, or
one of the generic labels such as `none`, `nan`, `na`, `null`, `unknown`, `p_value`, `symbol`) resolves
to the empty frame, because a build would never probe it either.

## Next Steps

Expand Down
8 changes: 5 additions & 3 deletions docs/api/lib.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ Filesystem path to the fullmap redb file, or a base directory containing it (res

Optional NCBI Taxon ID for filtering results to a specific organism.

Example: `"9606"` restricts **gene** matches to human-specific entries; non-gene categories (e.g., diseases, chemicals) are returned regardless of taxon. When `None`, no taxon filtering is applied and matches from all organisms are returned.
Example: `"9606"` keeps matches recorded to human and matches carrying no taxon (`TAXON_ID` 0), which is
why disease and chemical rows usually survive it, and drops matches recorded to any other taxon. When
`None`, no taxon filtering is applied and matches from all organisms are returned.

**`prioritize: Optional[list[Categories]]` (default: `None`)**

Expand All @@ -59,7 +61,7 @@ Example: `[Categories.GENE]` prevents gene mappings from appearing in the output

**`column_context: bool` (default: `True`)**

Controls category-frequency tie-breaking when multiple matches exist for a term. When `True`, the deduplication stage adds a category-frequency score (computed in Polars after the SQL query) and prefers the category that appears most frequently across all matched terms in the batch. When `False`, frequency-based tie-breaking is disabled.
Controls category-frequency tie-breaking when multiple matches exist for a term. When `True`, the deduplication stage adds a `FREQUENCY` column scoring how often each category appears across all matched terms in the batch, computed in Polars over the fetched rows, and prefers the most frequent category. When `False`, frequency-based tie-breaking is disabled and the column is absent.

This is useful when resolving a column of related entities (e.g., all genes): the shared context helps disambiguate terms that map to multiple categories.

Expand Down Expand Up @@ -164,7 +166,7 @@ The resolver requires a schema-v6 fullmap. Existing schema-v5 databases are reje

- If the `fullmap` path does not resolve to a valid redb file, or the file's schema tag doesn't match the expected version, the Rust extension raises a `RuntimeError`.
- If `entities` is empty, the function returns `[]`.
- Unresolved entities are silently filtered from the output (logged at INFO level by default via `resolve()`).
- Unresolved entities are filtered from the output and reported through `resolve()` logging.

## Integration

Expand Down
40 changes: 22 additions & 18 deletions docs/api/qc.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ The `qc` module validates entity-resolution mappings through a four-stage pipeli

QC runtime support is optional. Install `tablassert[qc]` to enable it: the extra pulls `scikit-learn` and `sentence-transformers` (`torch` and `numpy` arrive transitively); `rapidfuzz` is a core dependency and is always available.

`fullmap_audit()` checks the whole extra before it does any work and raises `QcRuntimeMissingError` naming every absent package and the install command. Checking up front matters because the two packages are needed at different stages (`scikit-learn` from the start, `sentence-transformers` only if Stage 4 is reached), so a half-installed extra would otherwise fail after the audit had already run. `build-kg --qc` performs the same check before the build begins, since the audit does not run until the very end of the build.
`fullmap_audit()` checks the whole extra before it does any work and raises `QcRuntimeMissingError` naming every absent package and the install command. Checking up front matters because the two packages are needed at different stages (`scikit-learn` from the start, `sentence-transformers` only if Stage 4 is reached), so a half-installed extra would otherwise fail after the audit had already run. `build-kg --qc` performs the same check before the build begins, so a missing extra fails immediately instead of after resolution has already cost time.

## fullmap_audit()

Expand Down Expand Up @@ -81,33 +81,35 @@ The function applies four validation stages in sequence. Each stage progressivel
**Fast path for high-confidence mappings.** A row passes if any of these hold:

```python
original == preferred_name
_qc_alias(original, aliases) == preferred_name
```

- The pre-resolution text exactly equals the resolved preferred name.
- The alias-resolved pre-resolution text equals the resolved preferred name. `aliases` maps a casefolded
source text to its preferred spelling, so a configured alias can pass on exact match.
- The resolved CURIE matches an exempt prefix (`CHEBI`, `PR`, `UniProtKB`, `NCBIGene`, `UMLS`, `UNII`, `PUBCHEM`, `MONDO`).
- The original text contains `:` (already looks like a CURIE).
- The preferred name matches an exception prefix (`^LOC` or `^si:`).

**Performance:** O(1) string comparison per row.

#### Stage 2: Fuzzy Matching

**Medium confidence using RapidFuzz (batched via `rapidfuzz.process.cpdist`).**

Two fuzzy matching algorithms:
1. **Ratio:** Overall string similarity
2. **Partial token sort ratio:** Combined token/subsequence matching
Three scorers run over each unique pending pair, batched through `rapidfuzz.process.cpdist`:
1. **Ratio:** `fuzz.ratio`, overall string similarity
2. **Partial token sort ratio:** `fuzz.partial_token_sort_ratio`, subsequence matching
3. **Token set ratio:** `fuzz.token_set_ratio`, duplicate-tolerant token overlap

**Thresholds:** `fuzz.ratio` >= 70 OR `fuzz.partial_token_sort_ratio` >= 80
A pair passes if any scorer clears its threshold. `fuzz.ratio` is compared against
`fuzzy_ratio_threshold` (default 70.0); the two token scorers share `fuzzy_partial_threshold`
(default 80.0). Both thresholds are graph-configurable through `qc_fuzzy_ratio_threshold` and
`qc_fuzzy_partial_threshold`.

```python
fuzz.ratio(original, preferred) >= 70
or fuzz.partial_token_sort_ratio(original, preferred) >= 80
fuzz.ratio(original, preferred) >= fuzzy_ratio_threshold
or fuzz.partial_token_sort_ratio(original, preferred) >= fuzzy_partial_threshold
or fuzz.token_set_ratio(original, preferred) >= fuzzy_partial_threshold
```

**Performance:** O(n) string operations, batched.

#### Stage 3: Abbreviation Expansion

**Deterministic pass for abbreviation/expansion pairs (Schwartz-Hearst).** A row passes when the pre-resolution text abbreviates the preferred name, or vice versa:
Expand All @@ -118,8 +120,6 @@ _is_abbrev(original, preferred_name) or _is_abbrev(preferred_name, original)

The matcher scans the short form right-to-left against the long form (case-insensitively); the first character of the short form must land on a word boundary of the long form. This rescues the class both fuzzy matching and embedding similarity can miss, the `AML` / `acute myeloid leukemia` pair.

**Performance:** O(n) character scans per row, no model inference.

#### Stage 4: SapBERT Semantic Similarity

**High confidence using SapBERT embeddings.**
Expand All @@ -134,7 +134,8 @@ similarity = cosine_similarity(embeddings[:n], embeddings[n:]).diagonal()
return similarity >= qc.SIMILARITY_THRESHOLD # 0.5
```

**Performance:** Expensive (transformer inference); the model is loaded once and cached.
The model is loaded once and cached for the lifetime of the process, so transformer inference is the
costly stage of the cascade.

### SapBERT Model

Expand Down Expand Up @@ -178,15 +179,18 @@ validated = fullmap_audit(

### Pipeline Flow

The cascade runs on unique `(original, preferred)` pairs, deduplicated before scoring, so the counts
below are pairs rather than source rows. Illustrative counts:

```
Input: 1000 rows with entity mappings
Input: 1000 unique pairs with entity mappings

Stage 1 (Exact): 700 pass, 300 pending
Stage 2 (Fuzzy): 250 pass, 50 pending
Stage 3 (Abbreviation): 10 pass, 40 pending
Stage 4 (SapBERT): 30 pass, 10 rejected

Output: 990 rows (700 + 250 + 10 + 30)
Output: 990 pairs pass (700 + 250 + 10 + 30)
```

### Confidence Levels
Expand Down
Loading
Loading