Skip to content

[superceded] Define a standard SSSOM hashing function. - #529

Closed
gouttegd wants to merge 7 commits into
masterfrom
add-hashing-function
Closed

[superceded] Define a standard SSSOM hashing function.#529
gouttegd wants to merge 7 commits into
masterfrom
add-hashing-function

Conversation

@gouttegd

@gouttegd gouttegd commented Apr 5, 2026

Copy link
Copy Markdown
Contributor

Resolves [#436]

  • docs/ have been added/updated if necessary
  • make test has been run locally
  • tests have been added/updated (if applicable)
  • CHANGELOG.md has been updated.

This PR adds a new section to the normative part of the website, intended for specifying stuff that are not part of the SSSOM data model nor of any of the SSSOM serialisation formats, but that should still be formally specified so that they can be implemented in the expected way by the various implementations.

The existing page about "chaining rules" is moved to that new section, as it does not really belong to the "data model".

Finally, the core of the PR is that a new page is added to the new section to define a standard SSSOM hashing function.

closes #436

This commit adds a new section to the normative part of the website,
intended for specifying stuff that are not part of the SSSOM data model
nor of any of the SSSOM serialisation formats, but that should still be
formally specified so that they can be implemented in the expected way
by the various implementations.

The existing page about "chaining rules" is moved to that new section,
as it does not really belong to the "data model".

A new page is added to the new section to define a standard SSSOM
hashing function.

closes #436
@gouttegd gouttegd self-assigned this Apr 5, 2026
@gouttegd
gouttegd requested review from cthoyt and matentzn April 5, 2026 15:35
Comment thread src/docs/spec-support-hashing.md Outdated
Comment thread src/docs/spec-support-hashing.md Outdated
yield the following hash (in hexadecimal):
`e3bc1b4b586c6e86d0caf369d49c161163e255c4a779821f448a8e4fbd616522`.

Finally, encoding the binary SHA2-256 hash in ZBase32 would yield the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is the brevity of the hash worth the complexity of encoding/unencoding in yet another step?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The output of the SHA2-256 hash function is a binary blob that cannot be used directly, so we need a post-hash encoding step anyway.

We could use hexadecimal encoding instead of ZBase-32 encoding, but that’s still “yet another step” that must be performed after the hashing.

As for which encoding to use, I raised that question here with some of the available options. Nobody weighed in on that issue.

@cthoyt cthoyt Apr 6, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer using hexadecimal digest of the SHA2-256 hash function over zbase-32 since this is more common, and it doesn't appear that there is either built-in support for zbase-32 nor an obviously well-maintained/authoritative implementation of it in Python

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rationale behind worrying about not having an authoritative implementation - this is becoming even more of a security risk if we have to include packages like this in our infrastructure given the current state of open source

@gouttegd gouttegd Apr 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still personally favour zbase-32, because it gives hashes that are more human-readable than all other possible encodings.

I am not convinced about the availability of “an obviously well-maintained/authoritative implementation of it in Python” being an issue. On what grounds do you say that the several available implementations are not “obviously well-maintained“? If it’s because they have not been updated in years, I don’t believe this is a valid criterion for a function like zbase-32. It’s perfectly normal for such a function not to require any maintenance after it has been implemented correctly.

I wrote a C implementation of it 9 years ago, I am still using it in some C projects to this day and I never had to retouch it since the first write.

Worst-case scenario, in case a Python developers really does not want to have to depend on an external library, implementing zbase-32 is trivial. Here’s for example my aforementioned C version:

static char zb32[] = "ybndrfg8ejkmcpqxot1uwisza345h769";

/**
 * Encodes a buffer in Z-Base32 encoding.
 * This function encodes data from the provided buffer and writes the
 * encoded data to the specified buffer.
 *
 * @param[in]  src  The input buffer.
 * @param[in]  slen The size of the @a src buffer.
 * @param[out] dst  The output buffer.
 * @param[in]  dlen The size of the @dst buffer.
 *
 * @return The number of bytes written to the output buffer.
 */
size_t
zbase32_encode(const char *src, size_t slen, char *dst, size_t dlen)
{
    size_t i, j, k;

    for ( i = j = 0; i < slen && j + 7 < dlen ; i += 5 ) {
        int64_t val;

        for ( val = k = 0; k < 5; k++ )
            if ( i + k < slen )
                val |= (int64_t)((unsigned char)src[i+k]) << ((4 - k) * 8);

        dst[j++] = zb32[(val & 0xF800000000) >> 35];
        dst[j++] = zb32[(val & 0x7C0000000)  >> 30];

        if ( i + 1 < slen ) {
            dst[j++] = zb32[(val & 0x3E000000) >> 25];
            dst[j++] = zb32[(val & 0x1F00000)  >> 20];
        }

        if ( i + 2 < slen )
            dst[j++] = zb32[(val & 0xF8000) >> 15];

        if ( i + 3 < slen ) {
            dst[j++] = zb32[(val & 0x7C00) >> 10];
            dst[j++] = zb32[(val & 0x3E0)  >>  5];
        }

        if ( i + 4 < slen )
            dst[j++] = zb32[val & 0x1F];
    }

    return j;
}

Porting it to Python is left as an exercise to the reader. ;)

Comment thread src/docs/spec-support-hashing.md Outdated

@cthoyt cthoyt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should consider choosing a canonical CURIE prefix and URI prefix for this hash, perhaps sssom.record and https://w3id.org/sssom/record/, such that it can be used in places where CURIEs/URIs are required.

we should probably also add an example that has a mapping annotating the record_id with this

Comment thread src/docs/spec-support-hashing.md
@gouttegd

gouttegd commented Apr 6, 2026

Copy link
Copy Markdown
Contributor Author

we should consider choosing a canonical CURIE prefix and URI prefix for this hash, perhaps sssom.record and https://w3id.org/sssom/record/, such that it can be used in places where CURIEs/URIs are required.

I believe this is beyond the scope of the spec. The spec defines the function. How people use it is none of the spec’s business.

In particular, I personally believe the hash should not be used to fill the record_id slot, because I believe that content-based identifiers are a very bad idea. If people want to do that, fine, the spec should not get in their way, but I don’t think the spec should give the impression that it is a correct thing to do.

Provide examples in SSSOM/TSV rather than JSON format. Add an example of
a mapping record making use of extension slots. Show for each example
the canonical S-expression in addition to the final hash value.
@cthoyt

cthoyt commented Apr 6, 2026

Copy link
Copy Markdown
Member

I believe this is beyond the scope of the spec. The spec defines the function. How people use it is none of the spec’s business.

Okay, fair. We are including non-spec documentation along with this kind of thing, so let's table this for future discussion after this PR.

In particular, I personally believe the hash should not be used to fill the record_id slot, because I believe that content-based identifiers are a very bad idea. If people want to do that, fine, the spec should not get in their way, but I don’t think the spec should give the impression that it is a correct thing to do.

Could you expand on this? I thought that one of the main ideas with record_id was to enable identifying records (however you want), and with this proposal was to give a standard way of doing that. This is something that's come up to enable interoperability with other ecosystems like JSKOS (and I maybe EDOAL?) and address some of the "give everything a PID" concerns from groups like RDA FAIR Mappings WG.

We don't have to make a recommendation to do it either way, but maybe saying in the non-normative documentation: here's what's possible, when using it is appropriate, and when/why it's not a good idea

Comment thread src/docs/spec-support-hashing.md Outdated
@gouttegd

gouttegd commented Apr 7, 2026

Copy link
Copy Markdown
Contributor Author

Could you expand on this? I thought that one of the main ideas with record_id was to enable identifying records (however you want)

Yes. Emphasis on the however you want. The spec providea a field to store a record identifier. How you mint an identifier is out of scope for the spec, which only mandates that the identifier must be “curifiable” (since record_id is typed as an EntityReference, meaning it is to be represented as a CURIE at least in SSSOM/TSV).

and with this proposal was to give a standard way of doing that.

First, I don’t believe there should be a “standard way” of minting identifiers for SSSOM mapping records. People should be able to use whatever method they want to mint identifiers. The spec should stay out of that. After all and as far as I know, none of the specs routinely used in the semantic world (e.g. RDF, OWL) try to enforce a specific way of identifying entities – all they require is that identifiers are some kind of strings and that they must obviously be unique; they don’t care what the strings represent, if they even represent something, or how they are generated. Why should SSSOM be different?

Some SSSOM users might want to use randomly generated UUIDs (because why not?):

record_id                                    subject_id   predicate_id      object_id
myset:FD83F991-4F96-4D65-B3D4-49CD57D28F24   FBbt:1234    skos:exactMatch   UBERON:5678
myset:1F5BB659-39DA-41DD-B7AD-E6AB10C7E3D7   FBbt:2345    skos:closeMatch   UBERON:6789

Some might want to use serially allocated numbers (my personal preference, for what it’s worth):

record_id    subject_id   predicate_id      object_id
myset:0001   FBbt:1234    skos:exactMatch   UBERON:5678
myset:0002   FBbt:2345    skos:closeMatch   UBERON:6789

Some others might want to use randomly generated words (weird, but again why not?):

record_id                            subject_id   predicate_id      object_id
myset:correct_horse_battery_staple   FBbt:1234    skos:exactMatch   UBERON:5678

Some others yet might want to use non-opaque strings that reflect somehow the content of the record (IMO a very bad idea, but I know some people do like the idea of non-opaque identifiers):

record_id                         subject_id   predicate_id      object_id
myset:FBbt1234_exact_UBERON5678   FBbt:1234    skos:exactMatch   UBERON:5678
myset:FBbt2345_exact_UBERON6789   FBbt:2345    skos:closeMatch   UBERON:6789

And yes, some might want to use a hash value calculated on the content of the record (hereafter called a “content-derived identifier”):

record_id                                                    subject_id   predicate_id      object_id
myset:jshtffs3jzdqe3fgtxqb4dzx5887dme1huwj4mjy3716x3u3jg7y   FBbt:1234    skos:exactMatch   UBERON:5678
myset:aw6m65rnuzdjto8yxooj1d87oimmz6hbtqn51quyi6kmwof9ntzo   FBbt:2345    skos:closeMatch   UBERON:6789

Again, I believe the spec should stay well clear of all that. The documentation might suggest some possibilities but the spec should absolutely not mandate or even recommend anything. “You want to give your records some unique IDs? Sure, here’s a slot (record_id) where you can store such IDs. Now, how you fill in that slot is entirely up to you – the same way that it’s up to you to decide how to identify your RDF resources or your OWL entities.”

Then there’s the specific issue with “content-derived identifiers”, and why do I think they are a particularly spectacularly bad idea. I’ve already given my thoughts in some details at several occasions on this repo (notably when we were discussing the introduction of the record_id slot), but I can do it again:

(A) Content-derived identifiers make it impossible to write a mapping set entirely by hand. Regardless of the hashing method used (even if we somehow used a method that is simpler than the one proposed in this PR), the hash cannot be reallistically be computed in someone’s head. You have to use a tool to produce it. This would break an important promise of SSSOM, which is that one can always manually craft a SSSOM set with no specialised tooling at all – just a plain old spreadsheet software.

(B) Content-derived identifiers are at risk of becoming “out-of-sync” with the records they supposedly identify. If you modify the record in any way but forget to re-run the ID-generating procedure (procedure that you have to run because, as stated in A, you cannot generate/update the identifier yourself), then you’ll end up with IDs that are no longer really derived from the content of the record.

(It could be that are fine with that outcome, but if so, that simply means that you didn’t even need content-derived identifiers to begin with, so why bother with them in the first place.)

(C) Content-derived identifiers deprive the set’s creators of the freedom to decide the difference between “updating an existing record” and “creating a new record”, because in fact there is no such thing as “updating a record” when using a content-derived identifier – any change to a record would cause the identifier to change, meaning that you are in effect always creating a new independent record. There may be cases where this is desirable, but I’ll wager those cases should be very few.

(D) As a direct consequence of C, content-derived identifiers are not stable, because again, any change to the record (even a semantically meaningless change like fixing a typo) would cause the identifier to change. I fail to imagine any scenario where that could be a good thing. What’s the point of treating mapping records as uniquely identifiable semantic entities (similar to a RDF resource or a OWL class) if I cannot reliably refer to them (like in a database or something)?

(E) The instability of content-derived identifiers is even worse in the specific case of SSSOM, because the content of a SSSOM record could change because of something that is out of the control of the record’s creator.

Consider the following record:

subject_id      subject_label   predicate_id                    object_id        object_label
FBbt:00000015   thorax          semapv:crossSpeciesExactMatch   UBERON:6000015   insect thorax

and now let’s imagine that Uberon curators decide to rename UBERON:6000015 from “insect thorax” to “thorax sensu Insecta”, because they decided that they prefer this way of of mentioning the species. The next time the mapping set is updated to be sure it is using the correct labels, the record thus becomes:

subject_id      subject_label   predicate_id                    object_id        object_label
FBbt:00000015   thorax          semapv:crossSpeciesExactMatch   UBERON:6000015   thorax sensu Insecta

In this scenario, the meaning of the record has not changed at all. UBERON:6000015 still represents the same concept as before, so this record still represents the very same mapping between the very same entities. And yet, because the label of UBERON:6000015 has changed, if records were identified using content-derived identifiers we would have to consider the second record as a different entity, identified with a different identifier, than the first. Did you already add some links to the record in your application database? Too bad, now you’ll have to update all references to that record so that they use the new identifier.

Again, I fail to imagine a scenario when that can be desirable.

Overall, content-derived identifiers can only be viable if some very specific conditions are met:

  • the data store (be it a database, a file, or whatever) where records are stored must be append-only; that is, it must not be possible to delete or modify existing records, you can only add new records;
  • whenever a new record is created by deriving from an existing record, there must be a way to get to the original record from the new record.

If you use SSSOM that way, then maybe content-derived identifiers can be fine. Otherwise, I believe they are an incredibly bad idea that people should not even consider until/unless they can explain very precisely why they think they actually need such identifiers. If they cannot, they should just stick to meaningless, opaque identifiers that are not tied to the content of the record – like most semantic identifiers.

Add a table explaining how to turn the value of an extension slot into a
string, based on the declared type of the extension.
@cthoyt

cthoyt commented Apr 7, 2026

Copy link
Copy Markdown
Member

It would be great to incorporate #529 (comment) into non-normative section of the documentation.

Could you also please lint the markdown using prettier npx --yes prettier --check --prose-wrap always --write src/docs/spec-support-hashing.md

After that and reconsidering using hex digests of sha hash, I think this is ready

@cthoyt

cthoyt commented Apr 7, 2026

Copy link
Copy Markdown
Member

Also FYI I have a working implementation of this in sssom-pydantic cthoyt/sssom-pydantic#75 (though extensions are not supported neither by the package as a whole, nor in the s-expression implementation)

@gouttegd

gouttegd commented Apr 7, 2026

Copy link
Copy Markdown
Contributor Author

It would be great to incorporate #529 (comment) into non-normative section of the documentation.

Could you also please lint the markdown using prettier npx --yes prettier --check --prose-wrap always --write src/docs/spec-support-hashing.md

Will do :) though that may have to wait until tomorrow.

gouttegd and others added 4 commits April 8, 2026 15:01
Run the following command:

  npx --yes prettier --check --prose-wrap always --tab-width 4 \
      --write src/docs/spec-support-hashing.md

Note the `--tab-width 4`! Without it, that piece of crap that is npx
prettier would completely mangle the nested lists.
This mostly turns my comment
(#529 (comment))
into a guidance page. Not convinced this is really useful, but that's
what has been asked in review.

@cthoyt cthoyt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're at an impasse about hashes - I wouldn't be as happy but would accept the zbase-32 proposal if @matentzn (or other community members) find Damien's arguments better than mine.

I am mostly happy with the record-identifiers.md. I think parts of it can be toned down a bit, but I don't want to hold up this PR on it. Let's make iterative improvements on that in follow-up PRs as needed.

Let's try and resolve the hash part then call this finished.

@gouttegd

gouttegd commented Apr 8, 2026

Copy link
Copy Markdown
Contributor Author

So @matentzn I guess you have the deciding vote! :D

Should the hash of a mapping record look like

  • 4jfngj8y8bh9fu7ahhj9ic6miqz78cskxhyo61zkgb3gjte3ocuo (ZBase-32 encoding), or
  • D24A2324E03879F2CFB8E713FAB3CBABAFD3B2CA7F010F4AEA307264C5198327 (hexadecimal encoding)?

I argue that a Z-Base32 hash is better for users, as it is smaller and easier to read and compare (in particular, less risks of mistaking a character for another, which in hexadecimal could easily happen e.g. between B and 8).

The fact that Z-Base32 could be slightly more complicated for developers (because they have to either find a library that provides the Z-Base32 encoding function, or implement that function themselves) should be irrelevant, or at least, less relevant than the benefit for users.

That being said, having a standard hash function is more important than the encoding used, so I’ll be in the same position as @cthoyt : if @matentzn or others find that hexadecimal encoding is better overall, I won’t be as happy but I can live with it. :)

@gouttegd

gouttegd commented Apr 10, 2026

Copy link
Copy Markdown
Contributor Author

I wonder if maybe we should replace the use of SHA2-256 by a simpler, non-cryptographic condensation function like FNV (RFC 9923).

After all we do not need the cryptographic properties (resistance to collisions and pre-image attacks) of SHA2-256 here. All we need is to be reasonably confident that two different mapping records (that have not been crafted specifically in order to produce a collision) will produce two different hashes. According to the birthday paradox, with a FNV64 hash, we would need to hash 2^32 records (that’s more than 4 billion records) before we have more than 50% chance of two different records inadvertently producing the same hash. In the context of SSSOM that sounds enough to me.

And a FNV64 hash ① would be much faster to compute and ② would fit in 8 bytes, so even a simple hexadecimal encoding would yield a very convenient 16-characters value (of the form 51B3BB3FF8664492), removing the need for any more complex or less common encoding such as ZBase-32.

@gouttegd

gouttegd commented Apr 10, 2026

Copy link
Copy Markdown
Contributor Author

It seems that Python’s standard library does not provide a FNV64 function, but implementing it is even more trivial than ZBase32:

FNV64_PRIME = 1099511628211
FNV64_OFFSET = 14695981039346656037

def fnv64(data: bytes) -> bytes:
    h = FNV64_OFFSET
    for byte in data:
        h = h ^ byte
        h = (h * FNV64_PRIME) % 2**64
    return h.to_bytes(8, "little")

@cthoyt

cthoyt commented Apr 13, 2026

Copy link
Copy Markdown
Member

I agree that cryptographic security isn't needed here. Why did you go for FNV64 instead of MD5? Again, I am thinking that picking well-known, highly available algorithms is the safer way (I had never heard of FNV before)

Also nit, almost all hex digests in the web world I've come in contact with are lowercased. Maybe this is different for other flavors of programmer

@gouttegd

gouttegd commented Apr 13, 2026

Copy link
Copy Markdown
Contributor Author

Why did you go for FNV64 instead of MD5?

Categorically against the use of MD5 here.

First because it would send confusing signals, given that MD5 is a cryptographic function that happens to be broken for most purposes: on one hand it would seem to suggest that we do want a cryptographic hash function (if we do not want that then why not pick a faster non-cryptographic hash function?), on the other hand it would seem to suggest that we don’t care that much about the cryptographic properties (if we do care about the cryptographic properties then why not pick a non-broken cryptographic hash function).

Second because choosing MD5 might not necessarily give implementers an easier job. MD5 is likely to be either absent or soon-to-be-removed from many crypto libraries, precisely because the function is broken (when cryptographic properties are required), obsolete, and should no longer be used.

Third because using MD5 is an invitation to receive many complaints and bug reports (even more so in the era of AI-generated bug reports) along the lines of “your project is using a dangerous, obsolete function”. I don’t want to have to justify over and over again our choice of MD5 (“no, in our case we do not need cryptographic properties, so we are fine with MD5, thank you”).

Either we use a cryptographic hash function, and in which case we should use a strong, non-broken one (SHA2-256 being the obvious choice: the SHA-3 competition a few years ago showed that the SHA2 family is much stronger than previously thought, and it is probably the most widely supported cryptographic hash function out there), or if we decide we don’t want need a cryptographic hash function, we make that decision explicit by deliberately choosing a non-cryptographic hash function – not by choosing a weak, broken cryptographic one.

As for which non-cryptographic hash function to use:

Again, I am thinking that picking well-known, highly available algorithms is the safer way (I had never heard of FNV before)

FNV-1a is just one of the most well-known non-cryptographic hash function. From Estébanez et al., 2014:

This function was designed by Glenn Fowler and Phong Vo in 1991 and later improved by Landon Curt Noll. It is one of the most efficient and widely used hash functions ever created.

While this paper concludes by recommending that developers looking for a non-cryptographic hash function adopt lookup3, MurmurHash2, or SuperFastHash (the functions that performed better in their tests), they also explicitly acknowledge that FNV-1a is the next best function, which can be considered whenever simplicity is requested:

Among these functions [the functions other than lookup3, MurmurHash2, SuperFastHash], we would recommend FNV, because it is the most commonly used in the software industry and its avalanche properties are better than those of others.

Lastly:

Also nit, almost all hex digests in the web world I've come in contact with are lowercased

I have no strong opinion on that and would not mind specifying that the hexadecimal encoding should use lower case characters. But funnily enough, on my side most hex digests I’ve come across are uppercased. :D

@cthoyt

cthoyt commented Apr 14, 2026

Copy link
Copy Markdown
Member

alright, I'd support FNV as a hash 👍

@gouttegd

Copy link
Copy Markdown
Contributor Author

And as for encoding the hash? Lowercase or uppercase hexadecimal?

I have a slight preference for uppercase because that’s what the definition for Base16 encoding (“Base16 encoding” being merely a fancy name for “hexadecimal”) in RFC 4648 says (which incidentally means that it’s also what Python’s base64.b16encode does), and I’d rather avoid having to say something like “encode the output of the FNV64 function in Base16 as per RFC 4648 but with lowercase characters”.

But I really don’t mind, so if you prefer lowercase that’d work for me.

@cthoyt

cthoyt commented Apr 14, 2026

Copy link
Copy Markdown
Member

let's just follow the RFC then.

so I think we're in agreement for all points now. Would you please decide which PR you want to move forward on, then we can call Nico for final approval

@gouttegd

Copy link
Copy Markdown
Contributor Author

Since we both agree on FNV64 + hexadecimal encoding, let’s go ahead with #534, unless someone wants to argue that we should in fact use a cryptographic hash function (and therefore stick with SHA2-256).

@cthoyt cthoyt changed the title Define a standard SSSOM hashing function. [superceded] Define a standard SSSOM hashing function. Apr 14, 2026
@gouttegd

Copy link
Copy Markdown
Contributor Author

Superseded by #534.

@gouttegd gouttegd closed this Apr 14, 2026
@gouttegd
gouttegd deleted the add-hashing-function branch April 14, 2026 13:19
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.

Define a standard hashing procedure

2 participants