Skip to content

obo: land ontology-onboarding — edges, registry, spine, rdf beside rf2 - #263

Merged
AdaWorldAPI merged 14 commits into
mainfrom
claude/onboarding-into-main
Aug 10, 2026
Merged

AdaWorldAPI merged 14 commits into
mainfrom
claude/onboarding-into-main

Conversation

@AdaWorldAPI

Copy link
Copy Markdown
Owner

Merges the stranded claude/ontology-onboarding branch (13 commits: edges.rs lane packing, registry.rs NsRegistry + band guard, spine.rs, RDF front-end, dedup/dangling fixes) into main, which had moved 4 commits ahead (#261 RO mints, #262 RF2 reader).

Conflict resolution: ogar-obo/src/lib.rs module list — both sides added modules (main: rf2; branch: registry, spine) — union, alphabetized. ogar-ro/src/lib.rs auto-merged: the concept-id collision guard (the classid-guard mirror half) now sits beside the 0xA0..0xA2 mints.

Verified: cargo test -p ogar-obo -p ogar-ro green (55 tests); full workspace builds.

Downstream: unblocks the MedCare-rs edge-agreement CI hooks (ogar_obo::edges::EdgeLanes), stranded against this branch since 2026-08-08.


Generated by Claude Code

claude added 14 commits August 7, 2026 23:53
…e CSR

The bake persisted the per-predicate degree histogram in the edge block
(`row_ptr`) but not the targets (`col_idx`), so a baked row could describe
its own local shape and still not be traversed. This adds the targets.

They ride the free value lanes in the one form the substrate has: a lane is
`classid(4) + 12 content bytes`, and the classid says how to read its own 12.
An edge lane reads its content as the G2 `4x24` carving — four literal links —
and carries the *target ontology's own row classid*, so a link is exactly the
`(classid, identity)` pair a key carries. The same binary search that resolves
a key resolves a link; nothing new is invented to point at a node.

Layout: the 480-byte value slab is 30 lanes. Lane 6 is the EntityType tenant;
lanes 0..6 stay reserved (RESERVE, DON'T RECLAIM); edges take lanes 7..30 =
92 link slots. Targets are emitted ascending by (predicate, target namespace,
numeric), and a lane never mixes target ontologies — its classid names exactly
one, so a namespace change starts a new lane even mid-predicate. Unused slots
are 0, which is unambiguous because no OBO CURIE numeric is 0.

The predicate is not stored on the lane. A reader recovers it from the degree
histogram, consuming `degree(p)` links per predicate in ascending order — which
makes the already-persisted `row_ptr` load-bearing instead of decorative, and
leaves a row that is a complete CSR adjacency row on its own.

`EdgeLanes` is the read side: it borrows the row and decodes a u24 with a
shift. No allocation, no gathered window, no owned copy of anything the lane
already holds.

Measured before the layout constant was chosen (`probe_edge_capacity`, real
sources): 186 029 triples over 68 789 subjects, max out-degree 62, max lane
demand for a single row 16 against a 23-lane budget. Both packing assumptions
verified on the same data — CURIE numeric 0 never occurs (so the pad is safe)
and every numeric fits 24 bits. The packer reports an overflow in `PackStats`
rather than truncating; `bake` accumulates it so a driver can refuse to ship
an artifact that is missing edges.

Verified end-to-end (`verify_edges`): links read back out of the row bytes
match the SPO triple table as a multiset, per ontology — RO 3 links / is_a 2,
UBERON 71 844 / 27 170, HP 50 284 / 31 284, PATO 3 389 / 2 909, MONDO 60 509 /
46 448; 186 029 packed, 0 dropped, 0 rows overflowed. Those are two different
code paths over the same input. The is_a totals also match the degree bytes in
the already-baked slabs, so the packing agrees with the artifact it extends.

RO is small enough to check by hand and is printed in full: RO:0002532
--Other--> RO:0002533, RO:0002533 and RO:0002534 --is_a--> RO:0002532, and
RO:0002577 correctly carries no link at all because its only parent
(BFO:0000040) is outside the five core namespaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCGhDYoQBXs3poaR7sFuqp
The crate doc said "Where this runs: BEFORE the bake, never after". That is
backwards and was ruled so: the joins are pre-bake, but ELK is not a join —
it observes the baked result and reports what follows from it. The bake is its
input, not its output. This corrects the doc, the Cargo description, and the
three consequences that were derived from the wrong premise.

The consequence that needed real rework was "types are legal here, nothing this
crate defines survives the bake". Post-bake that reads as a contradiction, and
the honest form is narrower: `ClassAddr` is not schema, it is the (classid,
identity) pair a baked key already carries, decoded from its position — the
same shape `ogar_obo::edges::Link` reads out of a lane. It declares nothing and
outlives nothing. `Subsumption` stays a distinct type for the one reason it was
ever worth one: so a caller cannot pass a part_of edge to a reasoner that has
no role composition.

The other two consequences survive unchanged and one gets stronger: post-bake,
"no file, no CURIE, no label" is not a discipline the crate has to keep — baked
rows carry no CURIE and no label to reach for.

Adds the adapter this rests on, `ogar_obo::edges::subsumptions`: the is_a-only
projection over baked rows, yielding (subclass, superclass) as the address pairs
the keys and lanes already hold. The is_a filter is the safety, not a
convenience — walking subsumption together with part_of derives false ancestors,
and filtering in the projection means a caller cannot forget to. Also adds
`unpack_key` (a row's own address, zero value decode) and `edges::subject`.

`elk_over_bake` runs the whole path on the real slab and cross-checks it against
a representation it does not share. Reading is_a out of the lanes yields 107 813
asserted subsumptions — the per-ontology degree totals summed — and closing them
yields 738 651 transitive ancestors and 0 equivalence cycles. The bake's own
`reason::saturate` reports 738 651 subsumption pairs and 0 cycles from the
out-of-line triple table. Two reasoners over two representations, identical to
the unit. Root counts agree too: RO has exactly 2 of its 4 rows with a parent,
which are RO:0002533 and RO:0002534.

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

Written down because the reasoning is not recoverable from the code: why the
G2 4x24 carving carries the targets rather than the cheaper 6x(u8:u8) rails
(a rail ref is basin-local and the bake has no basin — HEEL/HIP/TWIG are zero
on all 68,797 rows while identities run past 8.9 million, so a one-byte ref
has nothing to mean), and why identity does the opposite and rides the rail
(the same 24 bits as an X:Y pair with headroom, which a flat u24 cannot be
because it has no axis).

Records the measurements the layout constant was chosen FROM, not after: max
lane demand 16 against a 23-lane budget, and both packing assumptions checked
on the real sources — CURIE numeric 0 never occurs, so the pad is safe, and
every numeric fits 24 bits. Lane demand rather than out-degree is the binding
constraint, because the (predicate, namespace) grouping fragments lanes.

Names the three verification paths and what makes them independent, including
the one that matters most: closing the lanes gives 738,651 transitive
ancestors and 0 cycles, and the bake's own saturation computes the same two
numbers from the out-of-line triple table — different representation, different
code.

Separates ogar_obo::Predicate from the ogar-ro crate explicitly, since the
names invite conflation: the first is the byte palette carried on baked edges
(the THINK arm, what the graph asserts), the second is a callable vocabulary
at concept 0x0306 whose operands resolve against a basin-local codebook (the
DO arm). They meet at the ontology, not in the bytes.

Open ends are stated as open: the rails have no scheduled trigger, W-RO-5 is
undecided by both sides, and degree saturation at 255 is untested because the
measured maximum is 62 — if a source ever crosses it the histogram clamps
while the lanes still hold the targets, and the two halves would disagree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCGhDYoQBXs3poaR7sFuqp
The plan said to reach for TypedGraph::traverse / hdr_bfs. Reading them, both
take a concrete &GrBMatrix rather than a trait, and GrBMatrix's CsrStorage
holds values: Vec<BitVec> at 16384 bits — roughly 2 KiB PER EDGE. Materialising
the 107,813 is_a edges that way costs on the order of 200 MB to hold an
adjacency the rows already contain, which is the opposite of a large hydrated
table with a small resident set. traverse() also computes matrix.mxm(matrix),
which is one hop of the SQUARE, not a walk from a source. So the plan was wrong
on two counts and is corrected here: the lens IS the traversal, not an adapter
into blasgraph.

ogar-obo::spine — SpineLens<'a>, one borrowed field, in the house style of the
workspace's other lenses. resolve() is a binary search over (classid, num),
sound because the bake emits rows in that order: family holds the high half of
the numeric, so ordering by (classid, family, identity) IS ordering by
(classid, num). Verified over the shipped bake — 68,797 rows, zero inversions.
is_sorted() exists because an unsorted slice does not panic, it silently finds
nothing, so the precondition is checkable at the boundary where rows arrive.

ogar-elk::lens — Parents (one visitor method, so a hop allocates nothing) and
LensClosure. A blanket impl over closures keeps the seam free of a dependency
edge in either direction: the crate owning the byte layout hands over a closure
capturing its own view, and neither crate names the other's types.

NO HASHING on this path. Dedup is an ordered insert, membership is a binary
search. Not a micro-optimisation: addresses arrive already ordered from the
bake, hashing discards that ordering, and the ordering has to be rebuilt before
two ancestries can be intersected. Keeping it makes meet() a single merge pass
and makes two runs produce identical output — the property a layer emitting
facts has to have. Removing the hash sets took the full sweep from 7.8x the
indexed walk to 4.8x.

meet() is the Schnittpunkt: an x-hop cross-ontology lookup asks where reachable
sets MEET, and meeting is an intersection over sorted addresses — no vectors,
no per-edge payload, nothing serialized.

The lens never caches. A transitive closure recomputes equal, which under the
zero-copy law makes it a projection, and a projection is never stored however
much work produced it. Asking twice walks twice, deliberately.

Falsifier (examples/elk_lens_vs_closure, every class, no sampling): 68,786
classes compared, 0 ancestor sets differing, 738,651 transitive ancestors and 0
equivalence cycles from both the lens and the indexed closure. Cost is printed
whether or not it flatters the lens: 0 build vs 19 ms, 4.8x on a full sweep,
and the lens holds a slice where the index holds a map over 107,813 edges.

KNOWN LIMIT, measured not assumed: meet() between a MONDO and an HPO class
returns 0, because is_a never crosses ontologies. Cross-ontology Schnittpunkte
need the cross-predicates (has_phenotype / has_anatomy / has_quality), which are
present in the lanes — 78,216 of the 186,029 links — but which the closure
deliberately does not walk, since mixing part_of into subsumption derives false
ancestors. A typed cross-predicate walk is the next piece, not this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCGhDYoQBXs3poaR7sFuqp
meet over is_a alone can never cross an ontology — subsumption stays inside the
ontology that declares it. Crossing needs a ROLE (has_phenotype into HPO,
has_anatomy into Uberon) plus R-exists generalization of the FILLER up the
grounding spine.

The two spines are different and swapping them is the classic error, so both
are explicit parameters with no default that could silently be wrong:
grounding_parents_of is is_a UNION part_of (what a filler generalizes up);
parents_of stays is_a alone (what a subject subsumes through). Generalizing a
subject through part_of is unsound — A part_of B, B subclass C does not give
A subclass C — while generalizing a filler through it is the standard
inherit-grounding deduction.

TWO CORRECTIONS THIS COMMIT RECORDS, both caught by checks rather than by care.

1. The falsifier was originally aimed at reason::saturate's existential_inferred
and is NOT any more. Measured: the combined is_a UNION part_of spine carries
2,277 back edges even though is_a alone has zero, and ancestor_closure SKIPS a
back edge when it meets one. Its memo is therefore an incomplete closure whose
value depends on DFS start order — the same code over the same triples in two
orders produced 9,522,084 and 9,437,429. A number that moves cannot falsify
anything, so the check is now against an independent local computation that
shares no code with fillers_closed: 64 seeds, 0 divergences, and all 64 widened
(so the rule is not inert).

2. The first meet was sound and vacuous. 120 of 120 sampled disease pairs
"shared a phenotype" and the shared address was always HP:0000001 — the HPO
root — because every phenotype generalizes to "All". A result that fires on
every input carries as much information as one that never fires. most_specific
narrows a meet to members that are not an ancestor of another member, and the
sampled pairs then split 81 sharing something specific vs 39 sharing only the
root. It is deliberately NOT folded into meet_via: the raw meet is the sound
object and narrowing is the caller's presentation choice.

Scale is seeded, never global. A query starts from facts that already exist —
an observed lab code, a dispensed drug, a coded diagnosis — which is a handful
of addresses. An earlier draft summed R-exists over all 67,745 existential edges
to chase an aggregate; that is a theory-of-everything computation nobody asked
for and it is gone.

Controls that must keep holding: is_a finds 0 cross-ontology meets, and the
narrowed meet must produce BOTH specific and root-only pairs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCGhDYoQBXs3poaR7sFuqp
The bake reaches its namespaces through the `Namespace` enum, which spreads
one namespace's facts across four `match` bodies plus an index array
(`prefix` / `from_prefix` / `concept_id` / `namespace`). A sixth namespace
means editing five sites with hand-written literals — the shape that makes a
bake un-extendable without a code change per ontology.

`registry::NsRegistry` carries the same facts as one ordered table of
`NsSpec { prefix, concept_id }` rows. A driver that wants a different set of
namespaces passes a different table; it does not touch this crate's types.
`NsRegistry::parse` is the general form of `TermId::parse`, which is this
against `OBO_CORE`.

The row INDEX is the `TermId::ns` ordinal, and `bake` sorts ids by
`(ns, num)` — so table order determines the baked row order and therefore the
artifact digest. `OBO_CORE` is a second copy of the enum's facts and is only
safe to hold because `obo_core_matches_the_shipped_enum` fails the moment the
two disagree on order, prefix, or concept id. That is the drift guard, not a
formality: a reorder silently re-bakes every downstream slab.

`classify` deliberately stays on the enum — a `(subject_ns, object_ns)` pair
maps to an RO predicate, which is semantics between two named ontologies, not
a per-namespace attribute. A namespace outside the core classifies as
`Predicate::Other`, correct for upper-level spine ontologies whose edges are
`is_a` skeletons with no cross-ontology predicate to name.

Falsifiers, not assertions implied by the code:
- the mirror test fails on any reorder/renumber/rename;
- registry parse must agree with the shipped parse on all six accepts AND all
  five rejects (prefix outside table, >24-bit, malformed, no colon, wrong
  case) — otherwise routing a bake through the table would change which terms
  are tiled;
- the extended-table test asserts the shipped core CANNOT parse `BFO:0000002`,
  so a table that was silently ignored would fail rather than pass;
- ordinals are table-relative — MONDO is row 1 in the extended table and row 0
  in the core — pinning that a TermId is only meaningful WITH its registry.

No shipped digest moves: nothing in the bake path consults the registry yet.

34 tests pass; clippy -D warnings clean.
… numeric

The workspace already parses RDF — lance-graph-ontology's OwlHydrator reads
Turtle and RDF/XML through oxttl/oxrdfxml. I proposed building one before
checking, was corrected, and read it. It cannot serve as a bake front-end, for
two reasons found in `hydrate_many`:

  1. It DROPS the triple after interning. `OntologySlot` is
     `{ entity_count, iri_to_id }` and nothing else — no subClassOf survives
     hydration. `ContextBundle::edge_types` is a whitelist of predicate IRIs a
     traversal may follow, not stored edges.
  2. Its identity is an intern COUNTER, so it depends on file order. A bake
     keyed on it would not be reproducible.

`rdf` is the opposite of both, and is NOT a second parser — oxttl/oxrdfxml do
the reading, pinned to the same versions lance-graph-ontology pins, behind an
off-by-default `rdf` feature so the crate keeps its ogar-vocab-only profile.

Identity comes from the term: `…/obo/HP_0000118` -> `HP:0000118` -> 118, which
is the numeric pack_key writes on the family:identity rail. The extraction map
IS the NsRegistry — every OBO PURL is `<base><PREFIX>_<NNNNNNN>`, so a new
ontology is a table row, never a code path.

Predicates are read from `owl:onProperty` against OBO_RESTRICTION_PREDICATES,
not guessed from a namespace pair. parse_obo guesses deliberately, because the
`.obo` relationship label is unreliable; RDF names the property, so the guess
is unnecessary. Consequence stated in the docs: an RDF bake and a .obo bake of
the same ontology may type the same edge differently. The shipped five-namespace
bake was produced from .obo and is NOT re-baked here.

The table carries only the three CURIEs the crate already documents on
Predicate (BFO:0000050, RO:0002200, RO:0004026). HasAnatomy/HasQuality are
documented as harvested from logical definitions with no named property, so
there is no CURIE to add that would not be invented — those land on Other.

Documented limits, each with an assertion rather than a hope: restrictions
nested in an owl:intersectionOf collection are not walked; equivalentClass to
a NAMED class is skipped (equivalence is not subsumption — recording it as
is_a would invent a parent); a term becomes a row only on `a owl:Class`.

Falsifiers:
- order-independence: the same graph with stanzas reversed yields identical
  ids and edges, and the test asserts the reordered bytes actually differ. An
  intern-counter identity fails this.
- an EMPTY predicate table must classify everything as Other, proving the
  table is read rather than the mapping baked into the function.
- a unionOf blank node must contribute NO edge — the documented limit asserted,
  not hoped.
- a malformed document must error, so a parse failure cannot look like an
  empty ontology.
- the registry is genuinely consulted: the same IRI resolves under one table
  and not another.

Also adds META_STUDY_SPINE (8 rows, 0x0306..0x030D) as config, and
examples/probe_spine.rs, which measures the shipped path over real documents
instead of asserting it works. Measured, union of all eight:

  ns     classes   is_a  rel   max num  id==0  obsolete
  BFO         35    137    0       182      0     0
  COB         26     30   16      1300      0     9
  IAO        216    373   76   8000020      0    22
  OBI       4317   5928 1443   9999994      0   108
  OBCS       370    374   58       375      0     0
  SEPIO       62     46   16       435      1    15
  ECO       2299   3780    3   9000001      1    85
  FBbi       662    575   49    100028      1   107
  TOTAL     7987  11243 1661

1634 of 1661 typed edges land on Predicate::Other — reported, not guessed.

Two corrections this probe forced, recorded rather than quietly fixed:

- I earlier described the spine's bake order as "derived, not chosen,"
  implying a DAG. It is cyclic: OBI<->IAO (394/10), OBI<->COB (236/3),
  OBI<->OBCS (58/70). Nothing depends on acyclicity — bake takes the union and
  sorts by (ns, num) — so the doc now says order is a convention that fixes
  row order and therefore the digest, not a topological sort.
- The class counts supersede an earlier throwaway-script probe (OBI 4286->4317,
  IAO 210->216, SEPIO 57->62, COB 23->26, ECO 2303->2299, total 7946->7987).
  These come from the shipped path.

The id==0 measurement corroborates the earlier finding exactly — SEPIO, ECO
and FBbi each carry a real term at numeric 0, which edges.rs uses as the lane
pad meaning "lane spent, advance". An edge into such a term truncates a walk
silently, so META_STUDY_SPINE is documented as NOT bakeable for those three
until the +1 bias lands in edges.rs. That fix moves every shipped slab digest
and forces a re-bake of all five core tables, so it stays an operator call and
is not a side effect of adding a table.

42 tests pass; clippy -D warnings clean with and without the feature.
The lane pad was all-zero bytes, and the encoding relied on an assertion to
keep that unambiguous: "no OBO CURIE numeric is 0". That held for the five
baked namespaces and is false in general — probe_spine measured a real term at
numeric 0 in three further namespaces, one of them a namespace root.

The failure shape was the bad one. Removing the bias and running the new test
shows it directly:

    left: []   right: [0, 118]

Not [118]. BOTH links vanish. A zero slot does not drop one edge, it ends the
lane, so every target after it in that lane is lost too — and the artifact
still validates, so nothing anywhere reports it. A traversal simply returns
less than the graph contains.

Fix: store the numeric biased by one (encode_numeric/decode_numeric). Stored 0
means pad by construction rather than by a property of the input.
MAX_LINK_NUMERIC is 2^24-2 = 16_777_214 against a measured maximum of
9_999_994. The one unrepresentable numeric is reported as dropped, never
wrapped — with a test, because a guard that cannot be shown to fire is the
defect one level up.

Re-baked the core, as the gate required. Measured, not assumed:

  pre-A1  c88b44ea85a11bcf1e820b2aa03000fed157e39760ab3e5b83d40697a9f30bfb
  post-A1 424e81cce7050b593fbd61c3507530b3b80c5471d6f7180e5e834165b69b0ae6
  186,567 of 35,224,064 bytes differ (0.53%), 68,789 of 68,797 rows
  (the 8 unchanged rows are the ones with no out-edges)

Same graph, different bytes: rows 68,797, triples 186,029, links packed
186,029, dropped 0, overflowed 0, is_a cycles 0, dangling 0, saturation
738,651 subsumption pairs — every one identical to the shipped bake.
verify_edges re-checks every lane against the SPO triple table and the is_a
degree totals are unchanged (RO 2 / UBERON 27,170 / HP 31,284 / PATO 2,909 /
MONDO 46,448).

bake_obo did not print links_dropped / rows_overflowed at all. They were
computed and thrown away, so a run could look clean while the artifact was
missing edges — the same silent shape A1 is about, one level up. Now printed,
and asserted rather than warned about.

A1 existed to unblock the spine, so this finishes the job rather than stopping
at the bias. `bake` resolved classids through the hardcoded Namespace enum,
whose ordinal array panics for any ordinal >= 5, so no table but OBO_CORE
could bake. Added `bake_with(nodes, app_prefix, reg)`; `bake` is that against
OBO_CORE. Verified digest-neutral — the core re-bakes to 424e81cc unchanged.
A target whose namespace is absent from the registry is reported as dropped,
never guessed at.

examples/bake_spine.rs bakes all eight spine namespaces through the table with
no per-ontology code:

  rows 7,641 · triples 12,885 · packed 12,885 · dropped 0 · overflowed 0
  cycles 0 · dangling 0
  BFO 35 · COB 17 · IAO 194 · OBI 4,209 · OBCS 370 · SEPIO 47 · ECO 2,214 · FBbi 555
  sha256 437ea33aa7dda76bb37f13fd14849e8a339d71e354d7d90cea595b80875396c5

and asserts the gate directly: 10 edges point at a numeric-0 term, and 10 of
10 are read back out of the baked lanes. Before the bias each of those 10
truncated its lane.

1,628 of 12,885 typed edges land on Predicate::Other — no CURIE row matched
their owl:onProperty. Reported, not guessed.

Also declares required-features for the two rdf examples so
`clippy --all-targets` on the default feature set skips them rather than
failing.

46 tests pass; clippy -D warnings clean with and without the feature.
Found while auditing something else. I minted META_STUDY_SPINE at
0x0306..=0x030D yesterday. `ogar_ro::RELATION_BODY_CONCEPT_ID` is 0x0306 —
live, and already consumed by medcare-cohorts.

That is not a near-miss. A baked BFO row and an ogar-ro relation-body row
would carry the SAME classid, and nothing downstream could tell them apart.
The spine bake I shipped had 35 BFO rows sitting on that address.

The fix is not +1. `0x0300..=0x030F` is now the reserved core band — the five
OBO-core namespaces, ogar-ro, and room for nine more — and the spine starts at
the next clean boundary, 0x0310..=0x0317. Shifting by one would have parked
BFO at 0x0307, immediately adjacent to a live allocation, which is how this
happens again.

Two guards, because one cannot cover it. ogar-obo cannot see ogar-ro (the
dependency runs ogar-ro -> ogar-obo), so:

  - ogar-obo asserts the BAND: no spine row inside 0x0300..=0x030F, no core
    row outside it, no id claimed twice across both tables, and every id still
    inside the 0x03 Ontology domain.
  - ogar-ro asserts the CONSTANT: no ogar-obo namespace claims
    RELATION_BODY_CONCEPT_ID — plus an anti-vacuity test that the tables it
    checks are actually populated, since two empty tables would pass while
    proving nothing.

Both verified to fire: restoring 0x0306 fails with

    BFO at 0x0306 sits in the reserved core band (0x0300..=0x030F) — that is
    where ogar-ro's 0x0306 lives

Re-baked the spine. Classids are now 0x0310_0000..0x0317_0000, digest moves
437ea33a -> e1aa94d23bd3b2ffbf901591bcafb158dd625f4744a291026677349ad15d524f.
Everything else is unchanged: 7,641 rows, 12,885 triples, 0 dropped, 0
overflowed, and the A1 proof still holds — 10 numeric-0 links present, 10 of
10 read back from the lanes.

The core bake (424e81cc) is untouched: OBO_CORE's ids did not move.

Worth naming why this was invisible: the collision lived in two repos' worth
of hex literals with no test between them. It was found by hand-enumerating
the 0x03 domain during an unrelated audit, which is not a process that scales
— hence a guard on each side rather than a note.
…he union

S3 holds the bake both ways: one union `obo-core.soa` (what bakes.tsv pins)
and five per-namespace slabs. The splitter that made the second was not in the
repo, so re-syncing S3 after the A1 re-bake meant reconstructing it — which is
the same class of problem as a pin table living inside a shell script.

Splits `bake_with`'s output by namespace ordinal, asserting the ship gate
(links_dropped == 0, rows_overflowed == 0) before writing anything. Row counts
and byte sizes reproduce the slabs already on S3 exactly, so the only
difference against the previous upload is the corrected bytes.

Each slab verifies independently downstream: cross-slab references (HP->UBERON
in a HP-only slab) are counted separately from dangling ones, so a legitimate
partition does not read as a broken artifact.
…nding failed

META_STUDY_SPINE was moved to 0x0310..=0x0317 this morning after colliding
with ogar_ro's 0x0306. That block collided with FOUR consumer allocations:

  0x0311  COB   vs  OPS
  0x0313  OBI   vs  a product lane
  0x0315  SEPIO vs  the UMLS CUI convergence lane
  0x0317  FBbi  vs  Orphanet

The second attempt failed for a reason worth naming rather than patching: the
0x03 domain's occupancy was enumerated IN THIS REPO ONLY. A private consumer
holds an odd-stride run (0x0307, 09, 0B, 0D, 0F, 11, 13, 15, 17, 19, 1B, 1D)
that no test here can see, and it grows into odd slots — so a nearby block
collides eventually rather than immediately, which is the worse failure
because it lands long after the decision.

Moved to 0x0340..=0x0347, clear of both the core band and that run's growth.
But the spacing is a MITIGATION, not the guard, and the band test here is
weaker than it reads: it proves this table is self-consistent and clear of
upstream neighbours, which is exactly what it did while colliding with four
ids it had no way to enumerate. The doc now says that outright so nobody
reads it as coverage it does not have.

The real guard cannot live here. It has to see both sides, so it lives in the
consumer (medcare-cohorts::classid_census), which enumerates its own
allocations and checks them against ogar-obo's tables. Run against the
0x0310 block it reports all four collisions by name.

Spine digest moves e1aa94d2 -> a2205fb7255eb2801d0056944b364172d2be529ffb0ac6874ad5510bb13c3f06.
Rows, triples and the A1 proof are unchanged: 7,641 rows, 12,885 triples, 0
dropped, 10 of 10 numeric-0 links read back. The core bake is untouched.

Also fixed a test fixture the move caught: `a_different_table_...` built its
EXTENDED table from the spine's literal, so the table moved while its
assertion literal did not.
The bake reported `dangling: 0` for an artifact a reader finds 55 dangling
edges in. Both were right about what they measured, and the stat was measuring
the wrong thing.

`dangling` counted edges whose object is absent from the node MAP. An obsolete
term is IN the map — `parse_obo` / `parse_rdf` keep it, and `bake` deliberately
does not tile it — so an edge into one passed the check while pointing at a row
that does not exist. Found by running the edge-resolution hook against the
spine I had just published.

An edge into an obsolete term is itself obsolete, so it is now dropped rather
than emitted, and counted honestly: `dangling` means "object gets no ROW",
whether absent from the sources or present-but-obsolete.

Digest-neutral for the `.obo` core — no edge there targets an untiled row, so
`bake_obo` reproduces 424e81cc exactly. It is the RDF path, where
`owl:deprecated` classes are still referenced by live ones, that this corrects:
the spine drops 12,885 -> 12,830 triples, exactly the 55, and its digest moves
a2205fb7 -> 4c41538151b344e31db70d674697f01580e1c2b755bc12c796f6ec755d8f138f.

The two per-edge `dangling += 1` counters are now unreachable (the filter runs
first), so they became debug_asserts rather than dead arithmetic that would
quietly disagree with the filter if either changed.
…icates

Found by the edge-agreement check: the artifact's degree histogram totals
107,813 is_a slots while carrying 92,685 DISTINCT (subject, parent) pairs.
The gap is 15,128 duplicate is_a slots across 10,589 rows.

Cause: a driver that unions several sources concatenates parent lists.
`bake_obo` merges five .obo files with `extend`, so a term appearing in more
than one gets its parents twice. `rdf::parse_rdf` already dedups its own
output; doing it in `bake_with` covers every driver instead of every driver
having to remember.

Across all predicates the effect is larger than the is_a-only measurement
suggested: triples drop 186,029 -> 152,073, so 33,956 edges (18.2%) were
duplicates. `rel` carries them too, which the is_a-only probe could not see.

Semantically this changed nothing — `A is_a B` asserted twice is `A is_a B`,
so no answer was ever wrong. What it cost: lane budget, and an over-reporting
degree histogram, which is exactly what `resident_out_degree` reads. A row
claiming 62 out-edges could have had 56.

`BakeStats.duplicates` reports what was collapsed rather than silently
shrinking the edge count.

NOT re-published. This moves the core digest 424e81cc -> 75ec52a8 and changes
numbers that appear in the roadmap, the pipeline spec and the boot receipt
(186,029 triples is quoted in several places). That is a deliberate call, not
a side effect of a lint fix, and today has already spent three re-bakes. The
code is correct from here; the shipped artifact carries the duplicates and is
not wrong because of them.
…rdf land beside rf2

Resolves the module-list conflict in ogar-obo/src/lib.rs (both sides added
modules: main brought rf2, the branch brought registry + spine — union,
alphabetized). The ogar-ro side auto-merged: the concept-id collision guard
(the classid-guard mirror half) now sits beside the 0xA0..0xA2 mints that
landed on main after the branch diverged.

Verified: cargo test -p ogar-obo -p ogar-ro green (55 tests), full
workspace builds.

Unblocks the MedCare-rs edge-agreement CI hooks, which consume
ogar_obo::edges::EdgeLanes — stranded against this branch since 2026-08-08.

Co-Authored-By: Claude <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@AdaWorldAPI
AdaWorldAPI merged commit 9a153e7 into main Aug 10, 2026
1 check passed
@cursor

cursor Bot commented Aug 10, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_ed1c21c1-83af-47e6-bc87-ee1d7db2c4d0)

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.

2 participants