Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to this project are documented in this file.

### Added
- **The final graph QC now asserts no field in the emitted NDJSON is null or empty, and that every node carries a name.** `build-kg --qc`'s stage-7 study pass (in the spirit of `studyKGtsvs.pl`) gained two assertions. `empty-or-null-values`: any field in the nodes or edges file whose value is JSON `null`, a string that strips to empty, or an empty container — checked recursively, so a null or blank nested inside an `attributes` list counts — fails the build. The check is deliberately stricter than the NDJSON writer's `strip_nulls` (`rust/src/json.rs`), which scrubs dict entries at every depth but passes array scalars (`["x", ""]`) and emptied nested objects (`[{}]`) through verbatim; the study stage now asserts the stronger contract — no null or empty value anywhere — so the first such shape to reach an emitted file fails the build loudly instead of shipping silently. Null-like *strings* (`NA`/`NaN`/`null`/`none`) are also dropped by the writer but are neither null nor empty, and are deliberately not flagged; the `original_*` whitespace exemption does not extend to emptiness. `unnamed-nodes`: a node record whose `name` key is missing, `null`, or strips to empty fails the build — the missing-key case is what pipeline output surfaces, since `strip_nulls` deletes empty and null-like names before the file is written. Both report offenders per field or per node id, capped at 10 examples like the other assertions.
- **The final graph QC now asserts every node has an `id` and every edge has `subject`, `predicate`, and `object`.** Two more stage-7 study assertions join the `unnamed-nodes` check (which already requires a non-empty node `name`). `unidentified-nodes`: a node record whose `id` key is missing, `null`, or strips to empty fails the build; since the id is exactly what is absent, examples key on the node's `name` (or `<no name>` when it has none). `incomplete-edges`: an edge record missing any of the three core slots — missing key, `null`, or strips-to-empty — fails, counted per slot (e.g. `predicate (2)`). The writer's `strip_nulls` deletes a null slot outright rather than emitting it, so on pipeline output a hit means the slot was null upstream and the record shipped broken — exactly the condition these assertions exist to catch loudly. Non-string, non-null ids and slots pass, mirroring the name convention (no writer emits them).

## 12.1.0 - 2026-08-18

Expand Down
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ The positional `GRAPH-CONFIGURATION-FILE` (also `--configuration-file`, `-f`) is
| --- | --- | --- | --- | --- |
| `GRAPH-CONFIGURATION-FILE` (`--configuration-file`, `-f`) | Path | Yes | — | Graph YAML |
| `--release`, `-r` | Flag | No | `False` | Emit a slim, significant-only graph (drops `biolink:not_significant` edges before resolution) |
| `--qc`, `-q` | Flag | No | `False` | Audit resolved mappings (exact → fuzzy → abbreviation → SapBERT) so low-confidence edges are flagged; requires the `[qc]` extra, checked before the build starts. Also runs a final study stage that asserts over the emitted NDJSON — no duplicate node ids, no nodes with no name or an empty name, no undeclared or isolated nodes, no malformed lines, no null or empty values in any field (checked recursively), and no stray whitespace — verbatim `original_*` fields excepted from the whitespace check, since they are faithful source copies — and fails the build (non-zero exit) on any violation |
| `--qc`, `-q` | Flag | No | `False` | Audit resolved mappings (exact → fuzzy → abbreviation → SapBERT) so low-confidence edges are flagged; requires the `[qc]` extra, checked before the build starts. Also runs a final study stage that asserts over the emitted NDJSON — no duplicate node ids, every node has a non-empty `id` and `name`, every edge has a non-empty `subject`, `predicate`, and `object`, no undeclared or isolated nodes, no malformed lines, no null or empty values in any field (checked recursively), and no stray whitespace — verbatim `original_*` fields excepted from the whitespace check, since they are faithful source copies — and fails the build (non-zero exit) on any violation |
| `--log`, `-l` | Flag | No | `False` | Enable verbose per-section logging |
| `--head`, `-hd` | Flag | No | `False` | Fast output-shape preview: ≤5 random rows/section, cached to `.head.parquet`, never clobbers a full build |
| `--threads`, `-t` | int | No | `None` (auto) | Worker threads for the parallel fullmap reads behind entity resolution. Readers fan out across the 16 record-shard files, and values above the (non-empty) shard count further split the busiest shards' term buckets across more concurrent readers of the same shard — redb readers share-lock, so they never contend with each other. Unset keeps the auto behavior: large batches (≥ 1024 terms) fan out, small ones stay serial. Results are identical at any worker count |
Expand Down
8 changes: 4 additions & 4 deletions src/tablassert/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -665,10 +665,10 @@ def build_kg(
"tablassert[qc]"``); it is checked before the build starts, because the audit stage
runs LAST and a missing extra would otherwise surface only after entity resolution
has finished. It also runs a final study stage that asserts over the emitted NDJSON
-- no duplicate node ids, no nodes with no name or an empty name, no undeclared or
isolated nodes, no malformed lines, no null or empty values in any field, and no
stray whitespace -- and fails the build (non-zero exit) when any assertion is
violated.
-- no duplicate node ids, every node has a non-empty id and name, every edge has a
non-empty subject, predicate, and object, no undeclared or isolated nodes, no
malformed lines, no null or empty values in any field, and no stray whitespace --
and fails the build (non-zero exit) when any assertion is violated.
"""
# A non-positive thread count would only fail deep inside the Rust lookup; fail loud
# up front, matching the --gepa-threads pattern.
Expand Down
54 changes: 42 additions & 12 deletions src/tablassert/study.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"empty-or-null-values": "null or empty values",
"duplicate-node-ids": "duplicate node ids",
"unnamed-nodes": "nodes with no name or an empty name",
"unidentified-nodes": "nodes with no id or an empty id",
"incomplete-edges": "edges missing subject, predicate, or object",
"undeclared-nodes": "nodes referenced by edges but not declared in the nodes file",
"isolated-nodes": "declared nodes participating in no edge",
}
Expand Down Expand Up @@ -48,6 +50,8 @@ class _FileScan:
whitespace: Counter[str]
empty_null: Counter[str]
unnamed: Counter[str]
idless: Counter[str]
incomplete: Counter[str]
malformed: int
missing: bool
path: Path
Expand Down Expand Up @@ -81,15 +85,16 @@ def _scan_ndjson(path: Path, *, edge: bool) -> _FileScan:

Args:
path: Path to a ``.nodes.ndjson`` or ``.edges.ndjson`` file.
edge: ``True`` to collect referenced ids from ``subject``/``object``;
``False`` to collect declared node ``id``s and track duplicates and
nodes with no name or an empty name.
edge: ``True`` to collect referenced ids from ``subject``/``object`` and
assert the three core edge slots are present; ``False`` to collect
declared node ``id``s and track duplicates, nodes with no name or
an empty name, and nodes with no id or an empty id.

Returns:
A :class:`_FileScan`; ``missing`` is set (and nothing else) when the
file does not exist, so a typo'd path can never read as a clean pass.
"""
scan: _FileScan = _FileScan(set(), Counter(), Counter(), Counter(), Counter(), 0, not path.is_file(), path)
scan: _FileScan = _FileScan(set(), Counter(), Counter(), Counter(), Counter(), Counter(), Counter(), 0, not path.is_file(), path)
if scan.missing:
return scan
with path.open(encoding="utf-8") as handle:
Expand Down Expand Up @@ -126,6 +131,17 @@ def _scan_ndjson(path: Path, *, edge: bool) -> _FileScan:
ident: object = record.get(role)
if isinstance(ident, str):
scan.ids.add(ident)
# An edge without all three core slots is unusable downstream: KGX
# consumers traverse subject -> predicate -> object. Flag a missing
# key, a null, or a strip-empty string per slot, counted under the
# slot name (e.g. `predicate (2)`) for the examples list. The
# writer's strip_nulls deletes a null slot outright, so on pipeline
# output a hit means the slot was null upstream and the record
# shipped broken -- exactly what this assertion exists to catch.
for slot in ("subject", "predicate", "object"):
slot_value: object = record.get(slot)
if slot_value is None or (isinstance(slot_value, str) and not slot_value.strip()):
scan.incomplete[slot] += 1
else:
ident = record.get("id")
if isinstance(ident, str):
Expand All @@ -134,6 +150,15 @@ def _scan_ndjson(path: Path, *, edge: bool) -> _FileScan:
if ident in scan.ids:
scan.duplicate_ids[ident] += 1
scan.ids.add(ident)
# A node with no id is unusable downstream: the id is the graph's join
# key -- edges reference nodes only through it. Flag a missing key,
# a null, or a strip-empty string, mirroring the name assertion.
# Since the id is exactly what is absent, examples key on the node's
# name (or `<no name>`).
node_id: str = ident if isinstance(ident, str) else "<no id>"
name: object = record.get("name")
if ident is None or (isinstance(ident, str) and not ident.strip()):
scan.idless[name if isinstance(name, str) and name.strip() else "<no name>"] += 1
# A node with no name is unusable downstream: KGX consumers key display
# and merging off `name`. Flag a missing key, a null, or a string that
# strips to empty. On pipeline output the writer's strip_nulls
Expand All @@ -143,8 +168,6 @@ def _scan_ndjson(path: Path, *, edge: bool) -> _FileScan:
# non-null names pass -- no writer emits them. Offenders are keyed by
# node id (or `<no id>` when the record has no string id) for the
# examples list.
node_id: str = ident if isinstance(ident, str) else "<no id>"
name: object = record.get("name")
if name is None or (isinstance(name, str) and not name.strip()):
scan.unnamed[node_id] += 1
return scan
Expand All @@ -154,12 +177,13 @@ def study_kgx(nodes_path: Path, edges_path: Path, *, example_limit: int = 10) ->
"""Assert over the final KGX NDJSON files, in the spirit of studyKGtsvs.pl.

Streams both files once each and checks: duplicate node ids, nodes with no
name or an empty name, nodes referenced by edges but never declared
(``undeclared``), declared nodes participating in no edge (``isolated``),
empty/malformed lines, string values carrying leading/trailing whitespace,
and null or empty values in any field (a stronger contract than the writer's
strip_nulls). Every check is an assertion -- the caller decides whether
violations fail the build.
name or an empty name, nodes with no id or an empty id, edges missing any
of ``subject``/``predicate``/``object``, nodes referenced by edges but
never declared (``undeclared``), declared nodes participating in no edge
(``isolated``), empty/malformed lines, string values carrying leading/trailing
whitespace, and null or empty values in any field (a stronger contract than
the writer's strip_nulls). Every check is an assertion -- the caller decides
whether violations fail the build.

Args:
nodes_path: Path to ``<name>_<version>.nodes.ndjson``.
Expand Down Expand Up @@ -191,6 +215,12 @@ def study_kgx(nodes_path: Path, edges_path: Path, *, example_limit: int = 10) ->
if nodes.unnamed:
examples = [ident for ident, _ in nodes.unnamed.most_common(example_limit)]
violations.append(StudyViolation("unnamed-nodes", "nodes", sum(nodes.unnamed.values()), examples))
if nodes.idless:
examples = [name for name, _ in nodes.idless.most_common(example_limit)]
violations.append(StudyViolation("unidentified-nodes", "nodes", sum(nodes.idless.values()), examples))
if edges.incomplete:
examples = [f"{slot} ({n})" for slot, n in edges.incomplete.most_common(example_limit)]
violations.append(StudyViolation("incomplete-edges", "edges", sum(edges.incomplete.values()), examples))
if not nodes.missing and not edges.missing:
undeclared: list[str] = sorted(edges.ids - nodes.ids)
if undeclared:
Expand Down
Loading
Loading