use crate::query::{answer_query, AtomicScorer, Query, QueryConfig};
use crate::truth::Truth;
#[derive(Debug, Clone)]
pub struct AbduceConfig {
pub anchors: Option<Vec<usize>>,
pub max_conjuncts: usize,
pub beam: usize,
pub query: QueryConfig,
}
impl Default for AbduceConfig {
fn default() -> Self {
Self {
anchors: None,
max_conjuncts: 2,
beam: 16,
query: QueryConfig::default(),
}
}
}
#[derive(Debug, Clone)]
pub struct Hypothesis {
pub query: Query,
pub score: f32,
}
pub fn abduce<T: Truth>(
scorer: &dyn AtomicScorer,
observed: &[usize],
relations: &[usize],
config: &AbduceConfig,
) -> Vec<Hypothesis> {
let n = scorer.num_entities();
if observed.is_empty() || relations.is_empty() || n == 0 {
return vec![];
}
let mut obs = vec![0.0_f32; n];
for &e in observed {
if e < n {
obs[e] = 1.0;
}
}
let anchors: Vec<usize> = match &config.anchors {
Some(a) => a.clone(),
None => (0..n).collect(),
};
let mut atoms: Vec<(Query, Vec<f32>, f32)> = Vec::new();
for &h in &anchors {
for &r in relations {
let q = Query::anchor(h, r);
let degrees = answer_query::<T>(scorer, &q, &config.query);
let score = fuzzy_jaccard(°rees, &obs);
if score > 0.0 {
atoms.push((q, degrees, score));
}
}
}
atoms.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
atoms.truncate(config.beam);
let mut out: Vec<Hypothesis> = atoms
.iter()
.map(|(q, _, s)| Hypothesis {
query: q.clone(),
score: *s,
})
.collect();
if config.max_conjuncts >= 2 {
for i in 0..atoms.len() {
for j in (i + 1)..atoms.len() {
let degrees: Vec<f32> = atoms[i]
.1
.iter()
.zip(atoms[j].1.iter())
.map(|(&a, &b)| T::and(a, b))
.collect();
let score = fuzzy_jaccard(°rees, &obs);
if score > 0.0 {
out.push(Hypothesis {
query: Query::intersection(vec![atoms[i].0.clone(), atoms[j].0.clone()]),
score,
});
}
}
}
}
out.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
out.truncate(config.beam);
out
}
fn fuzzy_jaccard(degrees: &[f32], obs: &[f32]) -> f32 {
let (mut inter, mut union) = (0.0_f32, 0.0_f32);
for (d, o) in degrees.iter().zip(obs.iter()) {
inter += d.min(*o);
union += d.max(*o);
}
if union <= 0.0 {
0.0
} else {
inter / union
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::kg::FuzzyKg;
use crate::truth::Godel;
fn kg() -> FuzzyKg {
let mut kg = FuzzyKg::new(6);
kg.add_edge(2, 0, 1, 1.0); kg.add_edge(3, 0, 1, 1.0); kg.add_edge(1, 0, 0, 1.0); kg.add_edge(2, 1, 4, 1.0); kg.add_edge(3, 1, 4, 1.0); kg.add_edge(3, 1, 5, 1.0); kg
}
#[test]
fn recovers_a_planted_atom() {
let kg = kg();
let hyps = abduce::<Godel>(&kg, &[4], &[0, 1], &AbduceConfig::default());
let best = &hyps[0];
assert!((best.score - 1.0).abs() < 1e-6, "score {}", best.score);
match &best.query {
Query::Anchor { entity, relation } => {
assert_eq!((*entity, *relation), (2, 1), "expected dog-eats");
}
other => panic!("expected an atom, got {other:?}"),
}
}
#[test]
fn conjunction_beats_overbroad_atoms() {
let kg = kg();
let hyps = abduce::<Godel>(&kg, &[4, 5], &[1], &AbduceConfig::default());
match &hyps[0].query {
Query::Anchor { entity, relation } => {
assert_eq!((*entity, *relation), (3, 1), "expected cat-eats");
}
other => panic!("expected cat-eats atom, got {other:?}"),
}
assert!((hyps[0].score - 1.0).abs() < 1e-6);
}
#[test]
fn noise_degrades_score_not_ranking() {
let kg = kg();
let hyps = abduce::<Godel>(&kg, &[4, 0], &[1], &AbduceConfig::default());
let best = &hyps[0];
assert!(best.score < 1.0);
match &best.query {
Query::Anchor { entity, relation } => {
assert_eq!((*entity, *relation), (2, 1), "dog-eats still best");
}
other => panic!("expected atom, got {other:?}"),
}
}
#[test]
fn empty_inputs_yield_no_hypotheses() {
let kg = kg();
assert!(abduce::<Godel>(&kg, &[], &[0], &AbduceConfig::default()).is_empty());
assert!(abduce::<Godel>(&kg, &[4], &[], &AbduceConfig::default()).is_empty());
}
}