Skip to content

fix(turtle): make skolem IRIs collision-free and deterministic - #114

Merged
kervel merged 1 commit into
mainfrom
fix/skolem-path-collisions
Aug 25, 2026
Merged

fix(turtle): make skolem IRIs collision-free and deterministic#114
kervel merged 1 commit into
mainfrom
fix/skolem-path-collisions

Conversation

@kervel

@kervel kervel commented Aug 25, 2026

Copy link
Copy Markdown

The bug

Skolem IRIs are built from the child's key or list position, but not from the slot the child hangs off. Anything keyless at the same position under a different slot collides.

Input — src/runtime/tests/data/skolem_data.yaml, an instance of Person (shown as JSON):

{
  "id": "sktest:p1",
  "home_addresses": [ { "street": "home street" } ],
  "work_addresses": [ { "street": "work street" } ],
  "primary_contact": { "phone": "555-0001" },
  "backup_contact":  { "phone": "555-0002" },
  "accounts": {
    "savings_2024":         { "balance": "100" },
    "odd label/with slash": { "balance": "200" }
  }
}

Six distinct child objects. --skolem on main emits four subjects, because three of them land on p1/0:

sktest:p1 a sktest:Person ;
	sktest:accounts <https://example.com/skolem-test/p1/0> .
<https://example.com/skolem-test/p1/0> a sktest:Account ;
	sktest:balance "100" .
sktest:p1 sktest:accounts <https://example.com/skolem-test/p1/1> .
<https://example.com/skolem-test/p1/1> a sktest:Account ;
	sktest:balance "200" .
sktest:p1 sktest:home_addresses <https://example.com/skolem-test/p1/0> .    # ← collides
<https://example.com/skolem-test/p1/0> a sktest:Address ;
	sktest:street "home street" .
sktest:p1 sktest:primary_contact <https://example.com/skolem-test/p1/gen1> .
<https://example.com/skolem-test/p1/gen1> a sktest:Contact ;
	sktest:phone "555-0001" .
sktest:p1 sktest:backup_contact <https://example.com/skolem-test/p1/gen2> .
<https://example.com/skolem-test/p1/gen2> a sktest:Contact ;
	sktest:phone "555-0002" .
sktest:p1 sktest:work_addresses <https://example.com/skolem-test/p1/0> .    # ← collides
<https://example.com/skolem-test/p1/0> a sktest:Address ;
	sktest:street "work street" .

<.../p1/0> is simultaneously the savings account, the home address and the work address — one node typed as both Account and Address, carrying balance "100", street "home street" and street "work street". Silent data corruption.

Three separate defects are visible above:

  1. Slot missing from the path. home_addresses[0], work_addresses[0] and the first account all reduce to p1/0.
  2. gen{N} is a global counter walked in HashMap iteration order, used for objects with neither key nor position (primary_contact, backup_contact). The same input file gives different IRIs on different runs — six runs of one fixture alternated between backup_contact → gen1 and backup_contact → gen2.
  3. Inlined mappings numbered positionally. accounts is keyed by savings_2024 / odd label/with slash, but members were numbered 0/1 by enumeration order over a HashMap — discarding the key that already identified them, and picking a different one each run.

The fix

Put the slot in the path, drop the counter, use the mapping key where there is one. A skolem IRI becomes a pure function of the path walked to reach the node:

shape IRI
single-valued slot <parent>/<slot>
keyed member (mapping key or key: true slot) <parent>/<slot>/<key>
keyless list member <parent>/<slot>/<index>

Same input, after:

sktest:p1 a sktest:Person ;
	sktest:accounts <https://example.com/skolem-test/p1/accounts/odd%20label%2Fwith%20slash> .
<https://example.com/skolem-test/p1/accounts/odd%20label%2Fwith%20slash> a sktest:Account ;
	sktest:balance "200" .
sktest:p1 sktest:accounts <https://example.com/skolem-test/p1/accounts/savings_2024> .
<https://example.com/skolem-test/p1/accounts/savings_2024> a sktest:Account ;
	sktest:balance "100" .
sktest:p1 sktest:work_addresses <https://example.com/skolem-test/p1/work_addresses/0> .
<https://example.com/skolem-test/p1/work_addresses/0> a sktest:Address ;
	sktest:street "work street" .
sktest:p1 sktest:home_addresses <https://example.com/skolem-test/p1/home_addresses/0> .
<https://example.com/skolem-test/p1/home_addresses/0> a sktest:Address ;
	sktest:street "home street" .
sktest:p1 sktest:primary_contact <https://example.com/skolem-test/p1/primary_contact> .
<https://example.com/skolem-test/p1/primary_contact> a sktest:Contact ;
	sktest:phone "555-0001" .
sktest:p1 sktest:backup_contact <https://example.com/skolem-test/p1/backup_contact> .
<https://example.com/skolem-test/p1/backup_contact> a sktest:Contact ;
	sktest:phone "555-0002" .

Six inputs, six subjects, every IRI readable back to the path that produced it. Triple order still varies run to run (the writer walks a HashMap), but the graph no longer does.

Path segments now keep the RFC 3986 unreserved set (-._~) intact instead of escaping every non-alphanumeric. LinkML slot names are overwhelmingly snake_case, so the old encoder would have rendered home_addresses as home%5Faddresses once slot names entered the path. Characters that genuinely cannot appear in a path segment are still percent-encoded — see odd label/with slash above.

On .well-known/genid/

Worth recording, since it prompted the review: RDF 1.1 Concepts §3.5 recommends <authority>/.well-known/genid/<opaque> for skolem IRIs — but only for systems that "want Skolem IRIs to be recognizable outside of the system boundaries", i.e. marked as artifacts a consumer may map back to blank nodes. These path-derived IRIs are meant to be stable, meaningful addresses, which is the opposite claim. Deliberately not adopting genid.

Tests

New src/runtime/tests/turtle_skolem.rs, over the fixture above:

  • skolem_iris_do_not_collide_across_slots — the keyless children get distinct IRIs; home and work streets land on different subjects
  • skolem_iris_encode_the_slot_they_hang_off — exact expected IRI set
  • skolem_iris_are_keyed_by_key_not_index — mapping keys name their members; _ survives, / and space are escaped
  • skolem_iris_are_stable_across_serializations — eight independent load+serialize rounds produce an identical triple set

All four fail on main and pass here. Full workspace suite: 229 passed, 0 failed. cargo fmt --check clean; no new clippy warnings.

src/tools/tests/cli.rs updated — it asserted on the old root/gen1 name, now root/obj.

Not addressed

write_turtle's top-level Mapping arm numbers members the same positional way. LinkMLInstance::Mapping carries a mandatory slot and no loader produces one at the document root, so I could not reach it from a test and left it rather than changing untested code. Flagging it as latent.

Compatibility

This changes the IRI of every keyless nested object in --skolem output. Anything that has stored those IRIs will need to re-derive them.

🤖 Generated with Claude Code

Skolem IRIs were built from the child's key or its position alone, with the
slot it hangs off left out of the path. Two keyless objects under different
slots of the same parent therefore got the same IRI and were merged into a
single node:

    sk:r1 sk:homes <.../r1/0> .
    <.../r1/0> sk:street "home street" .
    sk:r1 sk:works <.../r1/0> .        # same subject
    <.../r1/0> sk:street "work street" .

Objects with neither a key nor a position fell back to a `gen{N}` global
counter walked in `HashMap` iteration order, so the same input produced
different IRIs on different runs. Inlined mappings were worse still: their
members were numbered by enumeration order over a `HashMap`, discarding the
mapping key that already identified them.

Put the slot in the path and drop the counter. A skolem IRI is now a pure
function of the path walked to reach the node:

    <parent>/<slot>                 single-valued slot
    <parent>/<slot>/<key>           keyed member (mapping key or key slot)
    <parent>/<slot>/<index>         keyless list member

Path segments keep the RFC 3986 unreserved set intact rather than escaping
every non-alphanumeric, so snake_case slot names and keys stay readable;
characters that cannot appear in a segment are still percent-encoded.

Not addressed: write_turtle's top-level `Mapping` arm numbers members the
same way, but `LinkMLInstance::Mapping` carries a mandatory slot and no
loader produces one at the document root, so the path is unreachable and
left untested rather than changed blind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kervel
kervel merged commit be1ecbe into main Aug 25, 2026
1 check passed
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