Skip to content

graph: calcify an imported ontology into LanceDB as 64k tables - #906

Closed
AdaWorldAPI wants to merge 2 commits into
mainfrom
claude/ontology-import-to-lancedb
Closed

graph: calcify an imported ontology into LanceDB as 64k tables#906
AdaWorldAPI wants to merge 2 commits into
mainfrom
claude/ontology-import-to-lancedb

Conversation

@AdaWorldAPI

@AdaWorldAPI AdaWorldAPI commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Ends the ontology import on disk. Baked node rows are written into LanceDB
datasets; from then on the filled database is what gets addressed, not the
in-memory artifact and not the source document.

Sibling of graph::hydrate, following its source → RecordBatch → Dataset::write shape.

The seam is bytes, not a type

&[u8] with a 512-byte stride. lance-graph takes no dependency on whichever
harvest produced the rows, and the seam cannot drift from the node layout
because it is the node layout (key(16) | edges(16) | value(480)). An OBO
harvest, a relational transcode, and a hand-built fixture all arrive the same
way.

64k tables

Rows are partitioned into one dataset per (classid, family). That is not a
chosen chunk size: identity is a u16, so the pair addresses exactly 65 536
slots — a table is a closed address space, 64 Ki × 512 B = 32 MiB at full
extent.

partition is a positional scan that cuts where the prefix changes. It never
sorts, groups, or moves a row, and it refuses unsorted input rather than
merging it — the alternative is one table silently written as two datasets.

One column, not three

node: FixedSizeBinary(512) over the caller's own allocation via
Buffer::from_custom_allocation, kept alive by the caller's Arc.

Splitting into key/edges/value columns would read better and would cost a
strided gather over every row — the copy this module exists to avoid. That
carving is instead a read-side projection over byte positions (KEY_RANGE /
EDGES_RANGE / VALUE_RANGE).

Read side

write_database is the import's last step; NodeTable::open is the addressing
that follows. It holds the dataset rather than a decoded copy, which is why
count_rows is async and fallible — a row count is a read against the data,
never a field the struct would have to keep in step with it.

Verification

  • cargo test -p lance-graph --lib graph::ontology_hydrate7 passed, 0 failed
  • cargo clippy -p lance-graph --lib — no findings in this module

The zero-copy claim rests on the_batch_borrows_the_callers_allocation, which
asserts pointer identity between the bytes Arrow exposes and the buffer handed
in — measured, not asserted in prose.

Note for anyone building this tree fresh: lance-encoding's build script
requires protoc (apt-get install protobuf-compiler), and a full
target/debug for this workspace runs to several GB.

Summary by CodeRabbit

  • New Features
    • Added ontology hydration for importing node data into Lance datasets.
    • Added validation for node-row layout, ordering, table capacity, and empty input.
    • Added support for zero-copy record batches backed by caller-owned memory.
    • Added tools to create, open, inspect, and count rows in generated node tables.
    • Added database-wide import orchestration with deterministic table naming and address tracking.

An import that stops at an in-memory artifact leaves every consumer to
slice the same blob its own way. This module ends the import on disk:
baked node rows go into Lance datasets, and from then on the filled
database is what gets addressed.

Sibling of `graph::hydrate`, following its source -> RecordBatch ->
`Dataset::write` shape.

The seam is bytes, not a type. `&[u8]` with a 512-byte stride, so
lance-graph takes no dependency on whichever harvest produced the rows
and the seam cannot drift from the node layout, because it is the node
layout. An OBO harvest, a relational transcode, and a hand-built fixture
all arrive the same way.

64k tables are not a chosen chunk size. `identity` is a u16, so a
(classid, family) pair addresses exactly 65_536 slots -- one table is a
closed address space, 64 Ki x 512 B = 32 MiB at full extent. `partition`
is a positional scan that cuts where the prefix changes; it never sorts,
groups, or moves a row, and it refuses unsorted input rather than
merging it (the alternative is one table silently written as two
datasets).

One column, not three. `node: FixedSizeBinary(512)` over the caller's
own allocation via `Buffer::from_custom_allocation`, with the caller's
`Arc` keeping it alive. Splitting into key/edges/value columns would
read better and would cost a strided gather over every row -- the copy
this module exists to avoid. That carving is instead a read-side
projection over byte positions (KEY_RANGE / EDGES_RANGE / VALUE_RANGE).

`write_database` is the import's last step; `NodeTable::open` is the
addressing that follows. It holds the dataset, not a decoded copy, which
is why `count_rows` is async and fallible -- a row count is a read
against the data, never a field this struct would have to keep in step
with it.

Verification: `cargo test -p lance-graph --lib graph::ontology_hydrate`
-- 7 passed, 0 failed; `cargo clippy -p lance-graph --lib` reports
nothing in this module. The zero-copy claim rests on
`the_batch_borrows_the_callers_allocation`, which asserts pointer
identity between the bytes Arrow exposes and the buffer handed in, so it
is measured rather than asserted in prose.
@cursor

cursor Bot commented Aug 7, 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_2b69325b-d7cb-464a-ab26-3c7281719b5c)

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AdaWorldAPI, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 37 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cf3f4be4-f3cf-4645-890f-53f4b597978e

📥 Commits

Reviewing files that changed from the base of the PR and between fb617bd and fc1fa12.

📒 Files selected for processing (1)
  • .claude/knowledge/s3-hydration-lifecycle.md
📝 Walkthrough

Walkthrough

The graph module now exposes ontology hydration APIs. The implementation validates and partitions sorted 512-byte node rows, builds zero-copy Arrow batches, writes per-table Lance datasets, imports databases, and reads table metadata.

Changes

Ontology hydration

Layer / File(s) Summary
Row layout and table partitioning
crates/lance-graph/src/graph/ontology_hydrate.rs
Defines the 512-byte row layout, table addresses, table runs, field accessors, projection ranges, and partitioning validation. Tests cover ordering, stride, capacity, empty input, naming, and projection ranges.
Zero-copy batches and dataset writing
crates/lance-graph/src/graph/ontology_hydrate.rs
Creates fixed-size-binary Arrow batches from caller-owned memory and writes each table run as a separate Lance dataset.
Database import and table access
crates/lance-graph/src/graph/ontology_hydrate.rs, crates/lance-graph/src/graph/mod.rs
Adds sequential database import orchestration, the NodeTable read API, and public module export.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant InputBuffer
  participant partition
  participant node_rows_batch
  participant write_table
  participant LanceDataset
  participant NodeTable

  InputBuffer->>partition: validate and split sorted node rows
  partition->>node_rows_batch: pass each TableRun byte range
  node_rows_batch->>write_table: return zero-copy RecordBatch
  write_table->>LanceDataset: create per-table dataset
  NodeTable->>LanceDataset: open dataset by TableAddr
  LanceDataset-->>NodeTable: provide row count and dataset handle
Loading

Suggested reviewers: claude

Poem

I’m a rabbit with rows in a neat little line,
Splitting each table run by class and design.
Zero-copy carrots in Arrow I bring,
Lance datasets bloom as the burrows all sing.
Hop through NodeTable, the harvest is bright!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: importing an ontology into LanceDB as 64k tables.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fb617bd9a6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// holds that `Arc` for the buffer's whole life, so the memory outlives every
// read. The buffer is never written through: `Buffer` is immutable, and the
// `cast_mut` above only satisfies `NonNull<u8>`'s signature.
let buffer = unsafe { Buffer::from_custom_allocation(ptr, bytes.len(), owner) };

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 Tie the Arrow buffer to its actual allocation

Because this safe public function accepts bytes and owner independently, a caller can pass a stack/local byte slice together with Arc<()>; the returned RecordBatch then outlives the slice and Arrow dereferences a dangling pointer, causing undefined behavior. The function must derive the pointer from the owning Arc (and an offset), copy the data, or expose the ownership relationship as an explicit unsafe precondition rather than assuming it in this unsafe call.

Useful? React with 👍 / 👎.

Comment on lines +194 to +195
if addr == current {
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject repeated or descending identities within a table

For same-table rows, this branch skips all validation of identity, so inputs such as identities [7, 3] or [7, 7] are accepted and persisted even though each identity is supposed to name one slot in the table. The later row-count check does not detect either case, allowing an import with malformed upstream ordering or duplicate keys to produce an ambiguously addressed dataset instead of being refused.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/lance-graph/src/graph/ontology_hydrate.rs`:
- Around line 351-356: Update the NodeTable documentation to replace the
nonexistent Self::row reference with the available Self::dataset reference,
while preserving the surrounding description of how rows are read.
- Around line 192-201: Update the row-order validation loop around table_addr_of
so it compares each row’s complete (classid, family, identity) key with the
previous row and rejects any non-increasing key, including descending or
duplicate identities; do not continue when addr == current. Add tests covering
descending and duplicate identities within a table.
- Around line 342-346: Update the import flow around partition and write_table
so database writes target a staging root rather than the final database root,
and publish a completion marker only after every run succeeds. Ensure failed
imports do not leave partial datasets visible and retries can start cleanly;
alternatively, add deterministic cleanup or resume handling before exposing the
database.
- Around line 253-271: Update node_rows_batch and the corresponding
write_table/write_database paths so buffer ownership is explicit: either mark
the API unsafe with documentation requiring owner to retain the allocation
backing bytes, or redesign the safe API to derive bytes from the Arc-owned
allocation. Ensure every Buffer::from_custom_allocation call preserves this
ownership invariant and cannot accept an unrelated Arc owner.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 08807898-4823-4652-b252-5a74f73cb51f

📥 Commits

Reviewing files that changed from the base of the PR and between c94db4b and fb617bd.

📒 Files selected for processing (2)
  • crates/lance-graph/src/graph/mod.rs
  • crates/lance-graph/src/graph/ontology_hydrate.rs

Comment on lines +192 to +201
for i in 1..n {
let addr = table_addr_of(bytes, i);
if addr == current {
continue;
}
if addr < current {
return Err(format!(
"rows are not sorted by (classid, family): row {i} is {addr:?} \
after {current:?}"
));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject unsorted and duplicate identities within a table.

Line 194 accepts every identity sequence when addr == current. Rows with identities 9, 2, or duplicate identity 9, pass validation. The resulting dataset can contain two rows for one addressable slot.

Compare the full (classid, family, identity) key. Reject a non-increasing key. Add tests for descending and duplicate identities in one table.

Based on PR objectives, partitioning must reject unsorted node rows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/lance-graph/src/graph/ontology_hydrate.rs` around lines 192 - 201,
Update the row-order validation loop around table_addr_of so it compares each
row’s complete (classid, family, identity) key with the previous row and rejects
any non-increasing key, including descending or duplicate identities; do not
continue when addr == current. Add tests covering descending and duplicate
identities within a table.

Comment on lines +253 to +271
pub fn node_rows_batch<O>(bytes: &[u8], owner: Arc<O>) -> Result<RecordBatch, String>
where
O: std::panic::RefUnwindSafe + Send + Sync + 'static,
{
if !bytes.len().is_multiple_of(NODE_ROW_STRIDE) {
return Err(format!(
"row buffer of {} bytes is not a multiple of the {NODE_ROW_STRIDE}-byte stride",
bytes.len()
));
}
let ptr = std::ptr::NonNull::new(bytes.as_ptr().cast_mut())
.ok_or_else(|| "row buffer pointer is null".to_string())?;

// SAFETY: `ptr`/`bytes.len()` describe exactly the caller's slice, and
// `owner` is an `Arc` over the allocation that slice points into. Arrow
// holds that `Arc` for the buffer's whole life, so the memory outlives every
// read. The buffer is never written through: `Buffer` is immutable, and the
// `cast_mut` above only satisfies `NonNull<u8>`'s signature.
let buffer = unsafe { Buffer::from_custom_allocation(ptr, bytes.len(), owner) };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect every public caller. Each byte slice must originate from the same Arc
# passed as the Arrow allocation owner.
rg -n -C 4 --glob '*.rs' \
  '\b(node_rows_batch|write_table|write_database)\s*\(' crates

Repository: AdaWorldAPI/lance-graph

Length of output: 3315


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file outline =="
ast-grep outline crates/lance-graph/src/graph/ontology_hydrate.rs --view expanded | sed -n '1,220p' || true

echo "== relevant source =="
sed -n '1,380p' crates/lance-graph/src/graph/ontology_hydrate.rs | cat -n

echo "== public exports / module path =="
rg -n --glob '*.rs' 'mod ontology_hydrate|ontology_hydrate::|node_rows_batch|write_table|write_database' crates/lance-graph/src

Repository: AdaWorldAPI/lance-graph

Length of output: 19603


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== arrow dependency versions =="
rg -n 'arrow\s*=|package ="arrow"' Cargo.toml crates -g 'Cargo.toml' || true
cat Cargo.lock 2>/dev/null | sed -n '/^\[\[package\]\]$/,/^\]$/{ /name = "arrow"/,/^\]\$/p }' | head -80
echo "== usages beyond crates tree =="
rg -n -C 3 --glob '*.rs' '\bnode_rows_batch\b|\bwrite_table\b|\bwrite_database\b' . || true

Repository: AdaWorldAPI/lance-graph

Length of output: 2500


Make custom-buffer ownership explicit or derive the slice from the allocation owner.

node_rows_batch accepts any Arc<O> while deriving bytes.as_ptr() from the argument slice. If a caller passes a view plus an unrelated owner, the returned RecordBatch can outlive the allocation backing the view. Make this unsafe with a clear requirement that the Arc owns the buffer allocation, or restrict the safe API to a slice derived from the Arc’s allocation and apply the same model through write_table and write_database.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/lance-graph/src/graph/ontology_hydrate.rs` around lines 253 - 271,
Update node_rows_batch and the corresponding write_table/write_database paths so
buffer ownership is explicit: either mark the API unsafe with documentation
requiring owner to retain the allocation backing bytes, or redesign the safe API
to derive bytes from the Arc-owned allocation. Ensure every
Buffer::from_custom_allocation call preserves this ownership invariant and
cannot accept an unrelated Arc owner.

Comment on lines +342 to +346
let runs = partition(bytes)?;
let mut written = Vec::with_capacity(runs.len());
for run in runs {
write_table(bytes, Arc::clone(&owner), run, db_dir).await?;
written.push(run.addr);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make failed database imports recoverable.

Line 345 writes directly to the final database root. If a later table write fails, earlier datasets remain visible. A retry then fails on the first existing dataset because WriteMode::Create is used.

Write to a staging root and publish a completion marker only after all writes succeed. Alternatively, implement deterministic cleanup or resume semantics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/lance-graph/src/graph/ontology_hydrate.rs` around lines 342 - 346,
Update the import flow around partition and write_table so database writes
target a staging root rather than the final database root, and publish a
completion marker only after every run succeeds. Ensure failed imports do not
leave partial datasets visible and retries can start cleanly; alternatively, add
deterministic cleanup or resume handling before exposing the database.

Comment on lines +351 to +356
/// A 64k table opened from disk — the addressable form.
///
/// Holds the Lance dataset, not a decoded copy of it. Reads go through
/// [`Self::row`], which returns the row's 512 bytes and leaves every reading of
/// them (key / edges / value, and whatever the classid says those mean) to the
/// caller.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the nonexistent Self::row reference.

NodeTable has no row method. The documentation directs callers to an unavailable API.

Reference Self::dataset instead, or add the documented method.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/lance-graph/src/graph/ontology_hydrate.rs` around lines 351 - 356,
Update the NodeTable documentation to replace the nonexistent Self::row
reference with the available Self::dataset reference, while preserving the
surrounding description of how rows are read.

States the one-way ratchet -- source -> ContextBundle -> node rows ->
LanceDB tables -> reads -- and the rule it exists for: nothing ever
hydrates upward. A consumer addresses the calcified form; it does not
parse, does not bake, and does not reach for a source artifact. The
moment one consumer parses at runtime, the source becomes a live
dependency of every deploy and the ratchet is gone.

Records why the CALCIFIED form is what sits in object storage rather
than the source: the expensive stages are paid once, every reader gets
the same bytes, addressing survives compression (the key is never
compressed), and a deploy needs an object-store client instead of a
format library per vocabulary.

Names two seams honestly, because both are load-bearing:

- `lance-graph-ontology::hydrators` produces a `ContextBundle` and
  stops. Nothing carries a bundle into node rows, so the Pattern-D path
  ends in memory -- it parses, and the result has nowhere to calcify to.
  The bake path reaches storage; the hydrator path does not. A session
  adding a `hydrate_*` today is extending the half with no floor.
- The bake emits an edge table beside the rows and only the rows are
  calcified, although the engine already answers reachability over
  relation matrices (`TypedGraph::traverse`, `blasgraph::ops::hdr_bfs`).
  Until the edges are carried there, every consumer needing an ancestor
  walk writes its own over whatever slice it can get.

Separates what is verified from what is not. Verified: the partition
cut, the mis-stride refusal, the pointer-identity assertion behind the
zero-copy write, the read-side range tiling. NOT verified: no
object-store URI has been exercised (every test runs on in-memory
buffers); zero-copy is measured up to the `RecordBatch` and NOT through
Lance's writer; the multi-table `write_database` -> `NodeTable::open`
composition has no test. Each is written as an invitation for the first
session that measures it to say so there.

Mechanism only -- no corpus, source, or terms appear, per the
public-repo rule. Credentials reach Lance through the environment or an
explicit `storage_options` map, never a committed path.
@AdaWorldAPI AdaWorldAPI closed this Aug 7, 2026
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