Skip to content

feat: declarative edge uuid_fields so edge ids stop drifting - #122

Merged
SkyeAv merged 6 commits into
mainfrom
feat/declarative-edge-uuids
Aug 25, 2026
Merged

feat: declarative edge uuid_fields so edge ids stop drifting#122
SkyeAv merged 6 commits into
mainfrom
feat/declarative-edge-uuids

Conversation

@SkyeAv

@SkyeAv SkyeAv commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Summary

Let a graph declare which edge fields constitute edge identity, so edge ids stop drifting on attribute-only changes — and make edge dedup key on the derived id, which guarantees uniqueness and cuts the dedup set from 2469 MB to 100 MB on a 1.27M-edge build.

Changes

  • What was changed

    uuid_fields (opt-in). Graph gains an optional uuid_fields list naming the fields that identify an edge; only those feed the derived id. Unset preserves today's whole-record hash, so the feature changes nothing for existing configs. A list that cannot be a key — empty, repeating, containing id, or naming a field no edge emits — is rejected at config time as uuid-bad-fields.

    Namespacing. Narrowing the hash inputs removes the accidental cross-graph uniqueness full-record hashing provided, so the UUID domain moves onto rig.source_info.infores_id when uuid_fields is set. New optional uuid_domain overrides it for graphs that must deliberately share an id space (sharded builds, a renamed KG keeping its published ids).

    Three correctness fixes that this design requires:

    • Nested values leaked key order into the hash. Only top-level keys were sorted; nested values reached the digest via Value::to_string, which under serde_json's preserve_order emits insertion order — so a polars struct-field reordering silently re-minted ids graph-wide. Canonicalization now recurses; array order is preserved because it is semantic.
    • false was dropped along with its key while strip_nulls deliberately keeps it, so {subject, negated: false} and {subject} — two distinct records — derived the same id.
    • Dedup keyed on full record bytes, safe only while the id was a pure function of those bytes. stable_json_bytes never sorted despite its name, so two logically identical records arriving with different key order derived one id, produced different bytes, and both shipped.

    Edges now dedup on the derived id. An exact repeat collapses; two genuinely different edges claiming one id raise uuid-fields-not-a-key with the id, the fields that differ, and one offending edge. Nodes are unchanged (their ids are CURIEs, not derived hashes).

    Also: a duplicate-edge-ids assertion in --qc study output (the symmetric partner to duplicate-node-ids, which had no edge equivalent).

  • Why this change is needed

    Every field was an identity field. A corrected p_value, a bumped subject_nlp_level inside supporting_text, a changed source_record_urls, or a reordered source row each minted a brand-new id — so downstream Translator consumers saw a new edge where they should have seen the same edge with updated attributes.

  • How it affects the project

    Measured on MultiomicsKG 3.0.0 (1,265,355 edges), re-analysing p_value and effect_size on every row:

    edge ids that changed
    no uuid_fields (today) 930,081 of 1,265,355 — 73%
    uuid_fields declared 0

    Dedup peak RSS on the same graph: 2469 MB → 100 MB (25x), and the pass went from 477s to 37s — the old path's allocation churn dominated, so that margin will be smaller on machines under less memory pressure.

Testing

  • Commands run and results

    • ruff check: passed · ruff format --check: 78 files already formatted · pyright: 0 errors
    • pytest: 1105 passed, 15 skipped (94% coverage)
    • cargo test: 89 + 10 + 14 passed, 0 failed · cargo fmt --check: passed · cargo clippy --all-targets -- -D warnings: clean

    Note: make check was run stage by stage rather than as one target — this machine's rustup toolchain ships a broken bundled linker wrapper (a dangling /nix/store path), so every cargo invocation needed RUSTFLAGS="-C link-self-contained=-linker -C link-arg=-fuse-ld=bfd". Unrelated to this change; CI is unaffected.

  • Unit tests added/updated

    Rust: golden vectors pinning three concrete UUIDs (nothing pinned an actual value before, so the encoding could change silently); nested key-order stability; array order still mattering; false staying distinguishable; declared fields ignoring undeclared edits while tracking declared ones; declared-field order not mattering; cross-domain separation; the not-a-key abort and its diagnostic; exact repeats still collapsing; key-order-only duplicates no longer shipping two lines.

    Python: Graph validation of uuid_fields (casing, empty, duplicate, id, unknown field), uuid_namespace across all four unset/set combinations, dedup_stream plumbing, and the duplicate-edge-ids study assertion.

  • Integration tests added/updated — none; covered by the real-data run below.

  • Manual testing performed

    Ran the deduper over the published 1.27M-edge MultiomicsKG 3.0.0 edges file with ids stripped back out, in four configurations. Output was 1,265,355 edges with 1,265,355 unique ids in every case.

    Worth flagging: the field set the docs suggest as a starting point is not a key for this graph — 2,476 collisions (0.2%), pairs whose subject, predicate, object, publication and row were identical and differed only in the NLP level in supporting_text, because two raw strings had resolved onto the same CURIE. The guard caught all of them. Adding supporting_text made it a key. The docs now say to build and let the failure name what is missing, rather than presenting a field list as an answer.

Related Issues

  • None

Checklist

  • Tests pass
  • Code style compliant
  • Documentation updated
  • No breaking changes

Breaking output change. Recursive canonicalization changes every existing edge id, once: every edge carries a sources array whose sorted key order differs from its insertion order. There is no old-id → new-id mapping; the old value was a hash of a serialization detail.

Migration: readers pinning Tablassert edge ids must re-key against the rebuilt graph. Adopt uuid_fields in the same rebuild and ids stop moving for attribute changes thereafter. The false fix moves nothing in practice (MultiomicsKG 3.0.0 contains zero false values).

Release is deliberately deferred

pyproject.toml stays at 15.1.0 in this PR. A version change landing on main is what triggers tag-version.yml and pipy.yml, so merging this does not tag or publish anything. The ## 16.0.0 CHANGELOG entry is written and ready; cut the release as a separate deliberate chore(release): 16.0.0 commit bumping pyproject.toml and CITATION.cff in lockstep.

Sizing is unambiguous when you do: this is a major — the edge id is a public contract and every one of them moves.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NuGRZ2fn3gE5zkUrv56Jqh

SkyeAv and others added 4 commits August 25, 2026 13:17
Edge ids were UUIDv3 hashes of the entire emitted record, so every field was
an identity field: a corrected p_value, a bumped nlp level inside
supporting_text, or a reordered source row each minted a brand-new id, and
downstream consumers saw a new edge rather than the same edge updated.

Graph gains an optional `uuid_fields` list naming the fields that constitute
edge identity; only those feed the hash. Unset preserves the whole-record
derivation, so the feature is strictly opt-in. Narrowing the inputs removes
the accidental cross-graph uniqueness full-record hashing provided, so the
UUID domain moves onto the graph's own infores when `uuid_fields` is set,
with `uuid_domain` as the escape hatch for graphs that must share an id space.

Three correctness fixes make this sound:

- Nested values leaked key order into the hash. Only top-level keys were
  sorted; nested values reached the digest via `Value::to_string`, which under
  serde_json's preserve_order emits insertion order. Canonicalization now
  recurses; array order is preserved because it is semantic.
- `false` was dropped along with its key while strip_nulls deliberately keeps
  it, so `{subject, negated: false}` and `{subject}` derived the same id.
- Dedup keyed on full record bytes, which was only safe while the id was a
  pure function of those bytes. Edges now dedup on the derived id itself, so
  the output can never carry one id twice: an exact repeat collapses, and two
  genuinely different edges claiming one id raise `uuid-fields-not-a-key`.

Keying on the id also drops the dedup set from a full copy of every record
(~800 bytes each) to 24 bytes per edge, and the content hash is computed
before the id is inserted so no record is cloned to strip it back out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuGRZ2fn3gE5zkUrv56Jqh
Nothing pinned an actual UUID value, so the derivation could change silently
and no assertion would fail. Three golden vectors now fix the full-record,
declared-field, and raw-parts derivations; if one moves, edge ids in every
published graph moved with it.

Also covers: nested key-order stability, array order still mattering, `false`
staying distinguishable, declared fields ignoring undeclared edits while
tracking declared ones, declared-field order not mattering, cross-domain
separation, the not-a-key abort and its diagnostic, exact repeats still
collapsing, key-order-only duplicates no longer shipping two lines, Graph
validation of uuid_fields, and the duplicate-edge-ids study assertion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuGRZ2fn3gE5zkUrv56Jqh
docs/api/utils.md documented a "\t".join(values) encoding removed some
releases ago (the join has been length-prefixed for collision-safety since)
and claimed edge ids covered only "subject, predicate, object, qualifiers,
and publication" -- which was never true, and is precisely what uuid_fields
now makes achievable. Both corrected, with the canonicalization, namespacing
and uniqueness rules documented alongside.

The graph config reference gains a Stable edge ids section: how to pick a
field set, why the set must be a key, how to read the uuid-fields-not-a-key
failure, when to reach for uuid_domain, and the one-time id churn that
adopting uuid_fields implies.

The guidance is empirical, not aspirational: the field set this page
recommends as a starting point is NOT a key for MultiomicsKG 3.0.0 (2,476
collisions in 1.27M edges, all pairs that differed only in the NLP level
recorded in supporting_text after two raw strings resolved onto one CURIE),
so the page says to build and let the failure name what is missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuGRZ2fn3gE5zkUrv56Jqh
Major: recursive canonicalization of nested values changes every existing
edge id once. Every edge carries a `sources` array whose sorted key order
differs from its insertion order, so the derivation moves for all of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuGRZ2fn3gE5zkUrv56Jqh
@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5488cda0-d319-47ca-a0fc-73a3dba367e0


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

SkyeAv and others added 2 commits August 25, 2026 13:28
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuGRZ2fn3gE5zkUrv56Jqh
This reverts commit 4a8d482.

Version stays at 15.1.0 so merging this does not trip tag-version.yml and
pipy.yml, which fire on a pyproject.toml version change landing on main. The
16.0.0 CHANGELOG entry stays as written and is ready for whenever the release
commit is made deliberately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuGRZ2fn3gE5zkUrv56Jqh
@SkyeAv
SkyeAv merged commit bf2d328 into main Aug 25, 2026
5 checks passed
SkyeAv added a commit that referenced this pull request Aug 25, 2026
Cut 16.0.0 and bump the package version in pyproject.toml, uv.lock, and
CITATION.cff.

Major: edge ids are canonicalized before hashing (nested object keys
sorted at every depth; false hashed instead of dropped), so every
existing edge id changes once — readers pinning ids must re-key against
the rebuilt graph. The same PR adds opt-in uuid_fields so a graph config
declares which edge fields constitute identity (930,081 of 1,265,355
ids moved under the whole-record hash on a re-analysis, none under a
declared list), derives the UUID namespace from the graph infores with
a uuid_domain override, aborts the build on duplicate derived ids
instead of shipping them, adds a duplicate-edge-ids QC assertion, and
cuts dedup memory 25x (2469 MB -> 100 MB peak, 477s -> 37s on
MultiomicsKG 3.0.0) (#122). Also adds config-driven rig.source_files
with indented RIG YAML output (#121).

Changelog:
- The 16.0.0 section shipped with #122; added the missing Added entry
  covering rig.source_files and the RIG YAML formatting from #121.

Docs: none needed here; both PRs shipped with their own docs.

Testing:
- uv run pytest -q -> 1107 passed, 15 skipped (94% coverage)
- uv run ruff check . && uv run ruff format --check . && uv run pyright -> clean / 0 errors
- uv lock --check -> up to date
- uv run mkdocs build --strict -> clean
SkyeAv added a commit to glusman-team/dakp that referenced this pull request Aug 25, 2026
Pin tablassert[qc]>=16.0.0,<17 and adopt 16.0's Graph.uuid_fields
(SkyeAv/Tablassert#122): edge ids now derive from exactly subject,
predicate, object, publications, FDA_regulatory_approvals, and
disease_context_qualifier, so attribute-only changes (case counts,
supporting_text, sources) stop minting new edges. A declared field
absent from a record contributes nothing to the hash, so the nullable
disease_context_qualifier (only some contraindication edges carry one)
is safe to declare graph-wide. The UUID namespace moves off the
historic TABLASSERT constant onto infores:multiomics-drugapprovals —
a one-time re-key of every edge id on the next build.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant