Skip to content
Merged
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
86 changes: 79 additions & 7 deletions crates/ogar-obo/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,11 +287,33 @@ fn pack_key(classid: u32, identity: u32) -> [u8; 16] {
let mut k = [0u8; 16];
k[0..4].copy_from_slice(&classid.to_le_bytes());
// 4..10 HEEL/HIP/TWIG = 0 (dormant cascade)
// 10..13 family = 0 (dormant basin)
let id = identity.to_le_bytes(); // low 3 bytes are the 24-bit identity
k[13] = id[0];
k[14] = id[1];
k[15] = id[2];
// 10..12 leaf = 0 (dormant 4th HHTL tier — RESERVE, DON'T RECLAIM)
//
// V3 tail (`canonical_node::NodeGuid::new_v2`): leaf:u16 @10..12,
// family:u16 @12..14, identity:u16 @14..16. This was the V1 u24 tail
// (identity's low 3 bytes at 13..16, family zeroed) until 2026-08-05;
// migrated because the contract defaults to `guid-v3-tail` and there are
// no pre-flip rows of this bake to preserve.
//
// The CURIE numeric does NOT fit `identity` alone — OBO ids run past
// 65_535 (MONDO:0700092 = 700_092), and `mint_for`'s V2/V3 arm asserts
// `identity <= 0xFFFF` rather than truncating. So the 24-bit numeric is
// carried by the family:identity RAIL, high half in `family`:
// family = num >> 16 (0..=255 for a 24-bit CURIE)
// identity = num & 0xFFFF
// That is the point of V3 over the flat u24 — a u24 has no axis and
// cannot carry an `X:Y` rail; this pair can, and it is lossless for the
// full 24 bits with 8 bits of headroom left in `family`.
//
// Sort order is UNCHANGED: `family` holds the high bits, so ordering by
// (classid, family, identity) is ordering by (classid, num) — the
// binary-search-by-key invariant over the baked artifact survives.
let f = ((identity >> 16) as u16).to_le_bytes();
let i = ((identity & 0xFFFF) as u16).to_le_bytes();
Comment on lines +311 to +312

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Encode the numeric using the actual V3 facet layout

When these rows are loaded under the advertised guid-v3-tail default, this writes the deprecated NodeGuid::new_v2 carving (leaf:u16 / family:u16 / identity:u16) into bytes that V3 defines as six independent (u8, u8) axes (CLAUDE.md:9-22 and docs/NODEGUID-CANON-AUDIT.md:80-94). For OBO IDs above 65,535, canonical V3 readers therefore see unrelated rail components and cannot reconstruct values such as 700_092; only this crate's custom decode_key understands the invented concatenation, breaking the stated cross-repository byte contract. Encode through the sanctioned V3 facet/ClassView layout, or explicitly select a registered legacy/wide carving instead of splitting the numeric across V2 fields.

Useful? React with 👍 / 👎.

k[12] = f[0];
k[13] = f[1];
k[14] = i[0];
k[15] = i[1];
k
}

Expand Down Expand Up @@ -598,8 +620,12 @@ pub fn rows_from_le_bytes(bytes: &[u8]) -> Option<&[Row512]> {
#[must_use]
pub fn decode_key(row: &Row512) -> (u32, u32) {
let classid = u32::from_le_bytes([row.0[0], row.0[1], row.0[2], row.0[3]]);
let identity = u32::from_le_bytes([row.0[13], row.0[14], row.0[15], 0]);
(classid, identity)
// V3 tail: reassemble the CURIE numeric from the family:identity rail
// (family @12..14 = high half, identity @14..16 = low half). See
// `pack_key` for why the numeric spans the pair rather than one field.
let family = u16::from_le_bytes([row.0[12], row.0[13]]);
let ident = u16::from_le_bytes([row.0[14], row.0[15]]);
(classid, (u32::from(family) << 16) | u32::from(ident))
}

const _: () = assert!(core::mem::size_of::<Row512>() == 512);
Expand Down Expand Up @@ -699,6 +725,52 @@ mod tests {
TermId { ns: ns as u8, num }
}

/// The V3 tail carries the CURIE numeric on the `family:identity` RAIL,
/// and this test is the reason the rail exists rather than a bare
/// `identity`.
///
/// **What would make it fail:** putting the numeric in `identity` alone.
/// Real OBO ids run past `u16` (MONDO:0700092 = 700_092), so a single-field
/// tail either truncates to 41_500 or trips `mint_for`'s
/// `identity <= 0xFFFF` assert. A small id like the `9` the existing
/// round-trip test uses cannot distinguish those layouts — it fits either.
///
/// Also pins the byte positions against `NodeGuid::new_v2`
/// (leaf @10..12, family @12..14, identity @14..16) so a silent
/// re-carve of the tail is caught here and not at a consumer.
#[test]
fn v3_tail_carries_oversize_curie_numerics_on_the_family_identity_rail() {
// Above u16 — the case a bare `identity` cannot hold.
let big = 700_092u32;
assert!(big > u32::from(u16::MAX), "fixture must exceed the u16 the rail exists for");
let k = pack_key(Namespace::Mondo.render_classid(0x0000), big);

// Byte positions, per new_v2.
assert_eq!(&k[10..12], &[0, 0], "leaf stays dormant (RESERVE, DON'T RECLAIM)");
let family = u16::from_le_bytes([k[12], k[13]]);
let identity = u16::from_le_bytes([k[14], k[15]]);
assert_eq!(u32::from(family), big >> 16, "family holds the high half");
assert_eq!(u32::from(identity), big & 0xFFFF, "identity holds the low half");

// Lossless through the public reader.
let mut row = Row512::zeroed();
row.0[0..16].copy_from_slice(&k);
let (cid, num) = decode_key(&row);
assert_eq!(num, big, "the rail round-trips the full 24-bit numeric");
assert_eq!(cid, Namespace::Mondo.render_classid(0x0000));

// The V1 read of the SAME bytes must NOT agree — proof the tail really
// moved, not that both layouts happen to coincide on this fixture.
let v1_read = u32::from_le_bytes([row.0[13], row.0[14], row.0[15], 0]);
assert_ne!(v1_read, big, "a V1 u24 read of a V3 row must be observably wrong");

// Ordering is preserved: family is the HIGH half, so (family, identity)
// sorts as the numeric does. This is what keeps binary-search-by-key
// valid over the sorted bake.
let lo = pack_key(Namespace::Mondo.render_classid(0x0000), 5_148);
assert!(lo[12..16] < k[12..16], "tail bytes order as the numeric orders");
}

#[test]
fn curie_parse_and_classid_canon() {
let t = TermId::parse("MONDO:0007739").unwrap();
Expand Down
Loading