use heyting::{
answer_query_topk, answer_query_topk_pruned, AtomicScorer, CandidateSource, Godel, Query,
QueryConfig,
};
use precinct::{AxisBox, IndexParams, Region, RegionIndex, SearchParams};
use serde_json::Value;
const CHECKPOINT: &str = "data/wordnet_boxes.json";
const HYPERNYM: usize = 0;
const EDGES: &str = include_str!("wordnet_edges.txt");
struct BoxKg {
boxes: Vec<AxisBox>,
}
impl BoxKg {
fn degree(&self, anchor: usize, e: usize) -> f32 {
if e == anchor {
return 0.0;
}
self.boxes[e].entailment_prob(&self.boxes[anchor])
}
}
impl AtomicScorer for BoxKg {
fn num_entities(&self) -> usize {
self.boxes.len()
}
fn project(&self, anchor: usize, relation: usize) -> Vec<f32> {
let n = self.num_entities();
if relation != HYPERNYM || anchor >= n {
return vec![0.0; n];
}
(0..n).map(|e| self.degree(anchor, e)).collect()
}
fn project_subset(&self, anchor: usize, relation: usize, candidates: &[usize]) -> Vec<f32> {
if relation != HYPERNYM || anchor >= self.num_entities() {
return vec![0.0; candidates.len()];
}
candidates
.iter()
.map(|&e| {
if e < self.num_entities() {
self.degree(anchor, e)
} else {
0.0
}
})
.collect()
}
}
struct SubsumerCandidates<'a> {
index: &'a RegionIndex<AxisBox>,
boxes: &'a [AxisBox],
min_prob: f32,
}
impl CandidateSource for SubsumerCandidates<'_> {
fn candidates(&self, anchor: usize, relation: usize) -> Option<Vec<usize>> {
if relation != HYPERNYM || anchor >= self.boxes.len() {
return Some(vec![]);
}
let params = SearchParams {
ef: 64,
overretrieve: 16,
};
let found = self
.index
.subsumers_soft(&self.boxes[anchor], self.min_prob, params)
.unwrap_or_default();
Some(
found
.into_iter()
.map(|(id, _)| id as usize)
.filter(|&id| id != anchor)
.collect(),
)
}
}
fn main() {
let path = std::env::var("WORDNET_BOXES").unwrap_or_else(|_| CHECKPOINT.to_string());
let Some(boxes_by_idx) = load_checkpoint(&path) else {
eprintln!("Trained WordNet boxes not found at {path}.");
eprintln!("Build with: scripts/fetch_wordnet_boxes.sh");
return; };
let mut id_of: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut name_of: Vec<String> = Vec::new();
for line in EDGES.lines() {
for tok in line.split_whitespace().take(2) {
if !id_of.contains_key(tok) {
id_of.insert(tok.to_string(), name_of.len());
name_of.push(tok.to_string());
}
}
}
let Some(boxes): Option<Vec<AxisBox>> = (0..name_of.len())
.map(|i| boxes_by_idx.get(&(i as u32)).cloned())
.collect()
else {
eprintln!("Checkpoint is missing boxes for some interned entities; re-fetch.");
return;
};
let dim = boxes[0].dim();
let mut index = RegionIndex::<AxisBox>::new(dim, IndexParams::default()).expect("index");
for (i, b) in boxes.iter().enumerate() {
index.add(i as u32, b.clone()).expect("add");
}
index.build().expect("build");
println!(
"Loaded {} trained WordNet concept boxes (dim {dim}).\n",
boxes.len()
);
let kg = BoxKg { boxes };
let source = SubsumerCandidates {
index: &index,
boxes: &kg.boxes,
min_prob: 0.01,
};
let config = QueryConfig::default();
let id = |name: &str| *id_of.get(name).expect("known concept");
let queries: Vec<(String, Query)> = vec![
(
"1p dog.n.01 is_a ?".into(),
Query::anchor(id("dog.n.01"), HYPERNYM),
),
(
"2p dog.n.01 is_a ∘ is_a ?".into(),
Query::anchor(id("dog.n.01"), HYPERNYM).then(HYPERNYM),
),
(
"2i (dog is_a ?) AND (cat is_a ?)".into(),
Query::intersection(vec![
Query::anchor(id("dog.n.01"), HYPERNYM),
Query::anchor(id("cat.n.01"), HYPERNYM),
]),
),
(
"2i (dog is_a ?) AND (eagle is_a ?)".into(),
Query::intersection(vec![
Query::anchor(id("dog.n.01"), HYPERNYM),
Query::anchor(id("eagle.n.01"), HYPERNYM),
]),
),
(
"lit (dog is_a ?) AND depth(?) <= 3".into(),
Query::intersection(vec![
Query::anchor(id("dog.n.01"), HYPERNYM),
Query::given(
depth_from_root(&id_of)
.iter()
.map(|d| match d {
Some(d) if *d <= 3 => 1.0,
_ => 0.0,
})
.collect(),
),
]),
),
];
let k = 3;
let mut disagreements = 0usize;
for (label, q) in &queries {
let dense = answer_query_topk::<Godel>(&kg, q, &config, k);
let pruned = answer_query_topk_pruned::<Godel>(&kg, &source, q, &config, k);
println!("{label}");
for (rank, (e, d)) in dense.iter().enumerate() {
let mark = match pruned.get(rank) {
Some((_, pd)) if (pd - d).abs() < 1e-6 => ' ',
_ => '!',
};
println!(" {}{} {:<20} {:.3}", mark, rank + 1, name_of[*e], d);
}
let dense_all = heyting::answer_query::<Godel>(&kg, q, &config);
let profile_matches = dense.len() == pruned.len()
&& dense
.iter()
.zip(pruned.iter())
.all(|((_, dd), (_, pd))| (dd - pd).abs() < 1e-6);
let degrees_genuine = pruned
.iter()
.all(|(e, pd)| (dense_all[*e] - pd).abs() < 1e-6);
if !(profile_matches && degrees_genuine) {
disagreements += 1;
println!(
" pruned diverges: {:?}",
pruned
.iter()
.map(|(e, d)| (name_of[*e].as_str(), *d))
.collect::<Vec<_>>()
);
} else if dense
.iter()
.map(|(e, _)| e)
.ne(pruned.iter().map(|(e, _)| e))
{
println!(" (tie-order differs; degree profiles identical)");
}
println!();
}
println!(
"pruned-vs-dense top-{k} agreement: {}/{} queries",
queries.len() - disagreements,
queries.len()
);
println!(
"(dense scores all {} entities per hop; pruned scores only precinct's\n\
soft-subsumer candidates, and the Godel min ranks general ancestors\n\
high -- generality weighting is the consumer's choice via log_volume.)",
kg.num_entities()
);
assert_eq!(disagreements, 0, "pruned top-{k} diverged from dense");
conformal_section(&kg, &id_of, &config);
}
fn conformal_section(
kg: &BoxKg,
id_of: &std::collections::HashMap<String, usize>,
config: &QueryConfig,
) {
let pairs: Vec<(Query, usize)> = EDGES
.lines()
.filter_map(|l| {
let mut it = l.split_whitespace();
let (child, parent) = (it.next()?, it.next()?);
Some((Query::anchor(id_of[child], HYPERNYM), id_of[parent]))
})
.collect();
let (calibration, test): (Vec<_>, Vec<_>) =
pairs.into_iter().enumerate().partition(|(i, _)| i % 3 != 0);
let calibration: Vec<(Query, usize)> = calibration.into_iter().map(|(_, p)| p).collect();
let test: Vec<(Query, usize)> = test.into_iter().map(|(_, p)| p).collect();
let alpha = 0.2;
let threshold =
heyting::calibrate::<Godel>(kg, &calibration, config, alpha).expect("calibrate");
let coverage = heyting::empirical_coverage::<Godel>(kg, &test, config, &threshold);
let mean_set_size = test
.iter()
.map(|(q, _)| heyting::answer_set::<Godel>(kg, q, config, &threshold).len())
.sum::<usize>() as f32
/ test.len() as f32;
println!(
"\nconformal answer sets (alpha = {alpha}): calibrated qhat = {:.3} on {} edges;\n\
held-out coverage {:.0}% over {} edges (nominal {:.0}%), mean set size {:.1} of {}\n\
entities. The guarantee is marginal and assumes exchangeable queries; hypernym\n\
edges at different taxonomy depths only approximate that, so read the coverage\n\
as an empirical check, not a certificate.",
threshold.qhat,
threshold.n_calibration,
coverage * 100.0,
test.len(),
(1.0 - alpha) * 100.0,
mean_set_size,
kg.num_entities(),
);
assert!(
coverage >= 1.0 - alpha - 0.15,
"coverage {coverage} far below nominal {}",
1.0 - alpha
);
}
fn depth_from_root(id_of: &std::collections::HashMap<String, usize>) -> Vec<Option<usize>> {
let mut parent: Vec<Option<usize>> = vec![None; id_of.len()];
for line in EDGES.lines() {
let mut it = line.split_whitespace();
if let (Some(c), Some(p)) = (it.next(), it.next()) {
parent[id_of[c]] = Some(id_of[p]);
}
}
(0..id_of.len())
.map(|mut e| {
let mut depth = 0usize;
while let Some(p) = parent[e] {
depth += 1;
e = p;
if depth > id_of.len() {
return None; }
}
Some(depth)
})
.collect()
}
fn load_checkpoint(path: &str) -> Option<std::collections::BTreeMap<u32, AxisBox>> {
let text = std::fs::read_to_string(path).ok()?;
let doc: Value = serde_json::from_str(&text).ok()?;
let boxes = doc.get("boxes")?.as_object()?;
let mut out = std::collections::BTreeMap::new();
for (k, v) in boxes {
let id: u32 = k.parse().ok()?;
let mu = as_f32_vec(v.get("mu")?)?;
let delta = as_f32_vec(v.get("delta")?)?;
out.insert(id, AxisBox::from_mu_delta(mu, delta));
}
(!out.is_empty()).then_some(out)
}
fn as_f32_vec(v: &Value) -> Option<Vec<f32>> {
v.as_array()?
.iter()
.map(|x| x.as_f64().map(|f| f as f32))
.collect()
}