Skip to content
Merged
Show file tree
Hide file tree
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
5 changes: 4 additions & 1 deletion crates/ogar-elk/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ license.workspace = true
repository.workspace = true
authors.workspace = true
rust-version.workspace = true
description = "The EL subsumption closure as a PRE-BAKE join validator. Answers two questions and no others: does `A ⊑ B` follow from what is asserted, and is adding a set of axioms to an existing closure sound (does it introduce an equivalence cycle). Entailments are facts — they follow necessarily — so nothing here is graded, ranked, or scored. Runs once, upstream of the bake, over the joined sources; nothing it defines survives into the baked substrate. Zero dependencies, and no serialization surface at all."
description = "The EL subsumption closure as a POST-BAKE observer. Answers two questions and no others: does `A ⊑ B` follow from the baked edges, and is adding a set of axioms to an existing closure sound (does it introduce an equivalence cycle). Entailments are facts — they follow necessarily — so nothing here is graded, ranked, or scored. Reads the `is_a` links carried in a baked row's own edge lanes; the joins happened upstream and are not its business. Zero dependencies, and no serialization surface at all."

[dependencies]

[dev-dependencies]
ogar-obo = { path = "../ogar-obo" }
229 changes: 229 additions & 0 deletions crates/ogar-elk/examples/elk_cross_ontology.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
//! S4b — the typed cross-predicate walk, verified on **seeded, local** queries.
//!
//! `meet` over `is_a` alone always returns nothing across ontologies:
//! subsumption never leaves the ontology it belongs to. Crossing needs a
//! **role** — `has_phenotype` into HPO, `has_anatomy` into Uberon — and then
//! R∃ generalization of the filler up the grounding spine.
//!
//! # Scale: seeded from facts, never a global saturation
//!
//! A query starts from facts that already exist — an observed lab code, a
//! dispensed drug, an imaging study, a coded diagnosis. That is a handful of
//! addresses. Saturating the whole ontology to answer something local is a
//! theory-of-everything computation nobody asked for, and this example
//! deliberately does not do it.
//!
//! # Why the check is NOT against `reason::saturate`
//!
//! Measured this session: the combined `is_a ∪ part_of` grounding spine carries
//! **2 277 back edges** (is_a alone has none), and `ancestor_closure` SKIPS a
//! back edge when it meets one (`!stack.iter().any(|(x, _)| *x == p)`). 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 this verifies the R∃ *semantics* on bounded queries against an
//! independent computation sharing no code with [`fillers_closed`].
//!
//! Usage: `cargo run --release -p ogar-elk --example elk_cross_ontology -- [slab.soa]`

use ogar_elk::{ClassAddr, LensClosure, Role, fillers_closed, meet_via, most_specific};
use ogar_obo::{NODE_ROW_STRIDE, Predicate, Row512, edges::EdgeLanes, spine::SpineLens};

fn main() {
let path = std::env::args()
.nth(1)
.unwrap_or_else(|| "/tmp/obo/obo-core.soa".to_string());
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
let n = bytes.len() / NODE_ROW_STRIDE;
let mut rows = vec![Row512::zeroed(); n];
for (i, r) in rows.iter_mut().enumerate() {
r.0.copy_from_slice(&bytes[i * NODE_ROW_STRIDE..(i + 1) * NODE_ROW_STRIDE]);
}
let lens = SpineLens::new(&rows);
assert!(lens.is_sorted());
println!("slab {path}\n rows {n}");

// The GROUNDING spine — is_a ∪ part_of. Deliberately NOT the subsumption
// source: a filler generalizes through mereology, a subject must not.
let grounding = |a: ClassAddr, visit: &mut dyn FnMut(ClassAddr)| {
lens.grounding_parents_of(a.classid, a.identity, &mut |c, num| {
visit(ClassAddr::new(c, num));
});
};
let fillers = |a: ClassAddr, r: Role, visit: &mut dyn FnMut(ClassAddr)| {
let p = match r.0 {
3 => Predicate::HasPhenotype,
4 => Predicate::HasLocation,
5 => Predicate::HasAnatomy,
6 => Predicate::HasQuality,
_ => Predicate::PartOf,
};
lens.fillers_of(a.classid, a.identity, p, &mut |c, num| {
visit(ClassAddr::new(c, num));
});
};
let has_phenotype = Role(Predicate::HasPhenotype as u8);

// ── seeds: the facts a query would actually start from ──
let mut seeds: Vec<ClassAddr> = Vec::new();
for row in &rows {
if EdgeLanes::new(&row.0).degree(Predicate::HasPhenotype) > 0 {
let s = ogar_obo::edges::subject(&row.0);
seeds.push(ClassAddr::new(s.classid, s.num));
if seeds.len() == 64 {
break;
}
}
}
println!(" seeds (diseases asserting a phenotype): {}", seeds.len());
assert!(!seeds.is_empty(), "no seed carries a phenotype");

// ── FALSIFIER: R∃ against an independent local computation ──
let t = std::time::Instant::now();
let mut checked = 0usize;
let mut widened = 0usize;
for &d in &seeds {
let got = fillers_closed(d, has_phenotype, &fillers, &grounding, 64);

// Independent path: asserted fillers off the row, then a hand-rolled
// BFS up the grounding spine. No shared code with `fillers_closed`.
let mut asserted: Vec<ClassAddr> = Vec::new();
lens.fillers_of(
d.classid,
d.identity,
Predicate::HasPhenotype,
&mut |c, num| asserted.push(ClassAddr::new(c, num)),
);
let mut want: Vec<ClassAddr> = Vec::new();
for &f in &asserted {
let mut queue = std::collections::VecDeque::from([(f, 0usize)]);
let mut seen: Vec<ClassAddr> = vec![f];
if let Err(at) = want.binary_search(&f) {
want.insert(at, f);
}
while let Some((x, depth)) = queue.pop_front() {
if depth >= 64 {
continue;
}
lens.grounding_parents_of(x.classid, x.identity, &mut |c, num| {
let p = ClassAddr::new(c, num);
if let Err(at) = seen.binary_search(&p) {
seen.insert(at, p);
queue.push_back((p, depth + 1));
if let Err(w) = want.binary_search(&p) {
want.insert(w, p);
}
}
});
}
}
assert_eq!(
got, want,
"R∃ diverges for 0x{:08x}:{}",
d.classid, d.identity
);
checked += 1;
if got.len() > asserted.len() {
widened += 1;
}
}
let el = t.elapsed();

println!("\n=== FALSIFIER (R∃, local) ===");
println!(" seeds checked : {checked} (0 divergences) in {el:?}");
println!(" seeds where R∃ widened : {widened} (must be > 0, else generalization is inert)");
assert!(
widened > 0,
"R∃ never added an ancestor — the rule is doing nothing"
);

// ── the Schnittpunkt is_a alone cannot find ──
println!("\n=== CROSS-ONTOLOGY SCHNITTPUNKTE ===");

// control: is_a alone must never leave the seed's own ontology
let isa = |a: ClassAddr, visit: &mut dyn FnMut(ClassAddr)| {
lens.parents_of(a.classid, a.identity, &mut |c, num| {
visit(ClassAddr::new(c, num));
});
};
let isa_lens = LensClosure::new(&isa);
let mut isa_cross = 0usize;
let k = seeds.len().min(16);
for i in 0..k {
for j in (i + 1)..k {
if isa_lens
.meet(&[seeds[i], seeds[j]])
.iter()
.any(|a| a.classid != seeds[i].classid)
{
isa_cross += 1;
}
}
}
println!(" via is_a : {isa_cross} pairs meeting OUTSIDE their own ontology (expected 0)");
assert_eq!(isa_cross, 0, "subsumption must not cross an ontology");

let mut found = 0usize;
let mut empty = 0usize;
let mut root_only = 0usize;
let mut specific = 0usize;
for i in 0..k {
for j in (i + 1)..k {
let m = meet_via(
&[seeds[i], seeds[j]],
has_phenotype,
&fillers,
&grounding,
64,
);
if m.is_empty() {
empty += 1;
continue;
}
found += 1;
// The raw meet is sound but vacuous: every phenotype generalizes to
// the HPO root, so every pair "shares" it. Narrow to the most
// specific common filler — the answer that carries information.
let narrowed = most_specific(&m, &grounding, 64);
let is_root_only = narrowed.len() == 1
&& lens
.resolve(narrowed[0].classid, narrowed[0].identity)
.is_some_and(|r| EdgeLanes::new(&rows[r].0).degree(Predicate::IsA) == 0);
if is_root_only {
root_only += 1;
} else {
specific += 1;
if specific <= 3 {
println!(
" 0x{:08x}:{} ∩ 0x{:08x}:{} -> {} shared, {} most-specific, e.g. 0x{:08x}:{}",
seeds[i].classid,
seeds[i].identity,
seeds[j].classid,
seeds[j].identity,
m.len(),
narrowed.len(),
narrowed[0].classid,
narrowed[0].identity
);
}
}
}
}
println!(" via role : {found} pairs share something | {empty} disjoint");
println!(" narrowed : {specific} pairs share something SPECIFIC | {root_only} only the root");
assert!(
found > 0,
"the role walk found no Schnittpunkt — is_a-only behaviour"
);
assert!(
specific > 0,
"no pair shared anything below the root — narrowing found nothing informative"
);
assert!(
root_only > 0 || empty > 0,
"every pair sharing something specific would mean the walk discriminates nothing"
);

println!("\nS4b VERIFIED: R∃ sound on seeded queries, roles cross, meets discriminate.");
}
149 changes: 149 additions & 0 deletions crates/ogar-elk/examples/elk_lens_vs_closure.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
//! The falsifier for the lazy lens, plus an honest cost comparison.
//!
//! The lens exists to reason over a hydrated table without copying it. That is
//! only worth anything if it gives the **same answers** as the indexed
//! [`Closure`], so this runs both over the real bake and compares them class by
//! class — not on a sample, on every class that has a parent.
//!
//! It also reports what the lens costs. The lens is not free and is not
//! uniformly faster; the numbers below are printed whether or not they flatter
//! it, because a lens that quietly lost on every axis would still pass a
//! correctness check.
//!
//! Usage: `cargo run --release -p ogar-elk --example elk_lens_vs_closure -- [slab.soa]`

use ogar_elk::{ClassAddr, Closure, LensClosure, Subsumption};
use ogar_obo::{NODE_ROW_STRIDE, Row512, edges, spine::SpineLens};

fn main() {
let path = std::env::args()
.nth(1)
.unwrap_or_else(|| "/tmp/obo/obo-core.soa".to_string());
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
assert!(bytes.len().is_multiple_of(NODE_ROW_STRIDE), "not a slab");

// Harness copy into an aligned buffer. On the real path Lance hands over an
// arrow-aligned FixedSizeBinary(512) column and this step does not exist.
let n = bytes.len() / NODE_ROW_STRIDE;
let mut rows = vec![Row512::zeroed(); n];
for (i, r) in rows.iter_mut().enumerate() {
r.0.copy_from_slice(&bytes[i * NODE_ROW_STRIDE..(i + 1) * NODE_ROW_STRIDE]);
}
println!("slab {path}\n rows {n}");

// ── the lens: borrow, assert the precondition, wrap in a Parents closure ──
let lens = SpineLens::new(&rows);
assert!(
lens.is_sorted(),
"binary-search resolve requires ascending (classid, num)"
);
let parents = |a: ClassAddr, visit: &mut dyn FnMut(ClassAddr)| {
lens.parents_of(a.classid, a.identity, &mut |c, num| {
visit(ClassAddr::new(c, num));
});
};
let lc = LensClosure::new(&parents);

// ── the indexed closure, for comparison ──
let t = std::time::Instant::now();
let cl = Closure::from_asserted(edges::subsumptions(&rows).map(|(s, p)| {
Subsumption::new(
ClassAddr::new(s.classid, s.num),
ClassAddr::new(p.classid, p.num),
)
}));
let build = t.elapsed();
let subs: Vec<ClassAddr> = cl.subclasses().collect();
println!(
" indexed closure: {} asserted edges over {} subclasses, built in {build:?}",
cl.asserted_len(),
subs.len()
);
println!(" lens: 0 edges indexed, 0 build time — it borrows the rows\n");

// ── THE FALSIFIER: identical ancestor sets, every class, no sampling ──
let t = std::time::Instant::now();
let mut total = 0usize;
let mut diverged = 0usize;
let mut first_bad: Option<ClassAddr> = None;
for &c in &subs {
let a = lc.supers_of(c);
let mut b: Vec<ClassAddr> = cl.supers_of(c).into_iter().collect();
b.sort_unstable();
if a != b {
diverged += 1;
if first_bad.is_none() {
first_bad = Some(c);
}
}
total += a.len();
}
let lens_walk = t.elapsed();

let t = std::time::Instant::now();
let control: usize = subs.iter().map(|&c| cl.supers_of(c).len()).sum();
let idx_walk = t.elapsed();

println!("=== FALSIFIER ===");
println!(" classes compared : {}", subs.len());
println!(
" ancestor sets differing: {diverged} (must be 0){}",
first_bad.map_or(String::new(), |c| format!(" first: {c:?}"))
);
println!(" transitive ancestors : lens {total} | indexed {control}");
assert_eq!(diverged, 0, "the lens disagrees with the indexed closure");
assert_eq!(total, control, "ancestor totals differ");

// Cycles: the lens is per-class by design (it has no membership roll).
let lens_cycles = subs.iter().filter(|&&c| lc.reaches_itself(c)).count();
let idx_cycles = cl.equivalence_cycles().len();
println!(" equivalence cycles : lens {lens_cycles} | indexed {idx_cycles}");
assert_eq!(lens_cycles, idx_cycles);

println!("\n=== COST (honest) ===");
println!(" build : lens 0s | indexed {build:?}");
println!(" full sweep : lens {lens_walk:?} | indexed {idx_walk:?}");
let ratio = lens_walk.as_secs_f64() / idx_walk.as_secs_f64().max(1e-9);
println!(" sweep ratio : lens is {ratio:.1}x the indexed walk");
println!(
" residency : lens holds a &[Row512] + a depth cap; the index holds a \n\
\x20 HashMap over {} edges for the life of the query set",
cl.asserted_len()
);

// ── Schnittpunkte: the cross-ontology x-hop lookup ──
println!("\n=== SCHNITTPUNKTE ===");
let mut seeds: Vec<ClassAddr> = Vec::new();
let mut by_class: std::collections::BTreeMap<u32, ClassAddr> = Default::default();
for &c in &subs {
by_class.entry(c.classid).or_insert(c);
}
for (_, c) in by_class.iter().take(2) {
seeds.push(*c);
}
if seeds.len() == 2 {
let m = lc.meet(&seeds);
println!(
" meet({:?}) -> {} shared ancestor(s)",
seeds
.iter()
.map(|s| format!("0x{:08x}:{}", s.classid, s.identity))
.collect::<Vec<_>>(),
m.len()
);
for a in m.iter().take(5) {
println!(" 0x{:08x}:{}", a.classid, a.identity);
}
// a class always meets itself in its own ancestry — a control that the
// primitive is not simply returning empty for everything
let self_meet = lc.meet(&[seeds[0], seeds[0]]);
println!(
" control meet(x,x) -> {} (equals |ancestors(x)| = {})",
self_meet.len(),
lc.supers_of(seeds[0]).len()
);
assert_eq!(self_meet.len(), lc.supers_of(seeds[0]).len());
}

println!("\nLENS VERIFIED: identical answers, zero index built.");
}
Loading
Loading