use crate::truth::Truth;
pub trait AtomicScorer {
fn num_entities(&self) -> usize;
fn project(&self, anchor: usize, relation: usize) -> Vec<f32>;
}
#[derive(Debug, Clone)]
pub struct QueryConfig {
pub beam_k: usize,
}
impl Default for QueryConfig {
fn default() -> Self {
Self { beam_k: 128 }
}
}
#[derive(Debug, Clone)]
pub enum Query {
Anchor {
entity: usize,
relation: usize,
},
Project {
inner: Box<Query>,
relation: usize,
},
Intersection {
branches: Vec<Query>,
},
Union {
branches: Vec<Query>,
},
Negation {
inner: Box<Query>,
},
Implication {
premise: Box<Query>,
conclusion: Box<Query>,
},
}
impl Query {
pub fn anchor(entity: usize, relation: usize) -> Self {
Query::Anchor { entity, relation }
}
pub fn then(self, relation: usize) -> Self {
Query::Project {
inner: Box::new(self),
relation,
}
}
pub fn intersection(branches: Vec<Query>) -> Self {
assert!(!branches.is_empty(), "intersection requires a branch");
Query::Intersection { branches }
}
pub fn union(branches: Vec<Query>) -> Self {
assert!(!branches.is_empty(), "union requires a branch");
Query::Union { branches }
}
pub fn negate(self) -> Self {
Query::Negation {
inner: Box::new(self),
}
}
pub fn implies(self, conclusion: Query) -> Self {
Query::Implication {
premise: Box::new(self),
conclusion: Box::new(conclusion),
}
}
}
pub fn answer_query<T: Truth>(
scorer: &dyn AtomicScorer,
query: &Query,
config: &QueryConfig,
) -> Vec<f32> {
let n = scorer.num_entities();
eval::<T>(scorer, query, config, n)
}
pub fn answer_query_topk<T: Truth>(
scorer: &dyn AtomicScorer,
query: &Query,
config: &QueryConfig,
k: usize,
) -> Vec<(usize, f32)> {
top_k_descending(&answer_query::<T>(scorer, query, config), k)
}
fn eval<T: Truth>(
scorer: &dyn AtomicScorer,
query: &Query,
config: &QueryConfig,
n: usize,
) -> Vec<f32> {
match query {
Query::Anchor { entity, relation } => {
let mut s = scorer.project(*entity, *relation);
s.resize(n, 0.0);
s
}
Query::Project { inner, relation } => {
let inner_scores = eval::<T>(scorer, inner, config, n);
project::<T>(scorer, &inner_scores, *relation, config, n)
}
Query::Intersection { branches } => {
let mut acc = vec![T::top(); n];
for branch in branches {
let s = eval::<T>(scorer, branch, config, n);
for (a, b) in acc.iter_mut().zip(s.iter()) {
*a = T::and(*a, *b);
}
}
acc
}
Query::Union { branches } => {
let mut acc = vec![T::bot(); n];
for branch in branches {
let s = eval::<T>(scorer, branch, config, n);
for (a, b) in acc.iter_mut().zip(s.iter()) {
*a = T::or(*a, *b);
}
}
acc
}
Query::Negation { inner } => {
let mut s = eval::<T>(scorer, inner, config, n);
for x in &mut s {
*x = T::neg(*x);
}
s
}
Query::Implication {
premise,
conclusion,
} => {
let p = eval::<T>(scorer, premise, config, n);
let c = eval::<T>(scorer, conclusion, config, n);
p.iter()
.zip(c.iter())
.map(|(&pi, &ci)| T::residuum(pi, ci))
.collect()
}
}
}
fn project<T: Truth>(
scorer: &dyn AtomicScorer,
inner_scores: &[f32],
relation: usize,
config: &QueryConfig,
n: usize,
) -> Vec<f32> {
let beam = top_k_descending(inner_scores, config.beam_k);
let mut out = vec![0.0_f32; n];
for &(v, v_score) in &beam {
if v_score <= 0.0 {
continue;
}
let tails = scorer.project(v, relation);
for (t, &tail) in tails.iter().enumerate().take(n) {
let combined = T::and(v_score, tail);
if combined > out[t] {
out[t] = combined;
}
}
}
out
}
fn top_k_descending(scores: &[f32], k: usize) -> Vec<(usize, f32)> {
let mut idx: Vec<(usize, f32)> = scores.iter().copied().enumerate().collect();
idx.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
idx.truncate(k);
idx
}
#[cfg(test)]
mod tests {
use super::*;
use crate::truth::{Godel, Lukasiewicz, Product};
use crate::FuzzyKg;
fn taxonomy() -> FuzzyKg {
let mut kg = FuzzyKg::new(6);
kg.add_edge(3, 0, 1, 1.0); kg.add_edge(4, 0, 1, 1.0); kg.add_edge(5, 0, 2, 1.0); kg.add_edge(1, 0, 0, 1.0); kg.add_edge(2, 0, 0, 1.0); kg.add_edge(3, 1, 4, 0.8); kg
}
#[test]
fn anchor_returns_direct_neighbours() {
let kg = taxonomy();
let cfg = QueryConfig::default();
let q = Query::anchor(3, 0);
let scores = answer_query::<Godel>(&kg, &q, &cfg);
assert_eq!(scores.len(), 6);
assert!((scores[1] - 1.0).abs() < 1e-6, "dog is_a mammal");
assert!(scores[0].abs() < 1e-6, "not directly animal");
}
#[test]
fn chain_2p_reaches_grandparent() {
let kg = taxonomy();
let cfg = QueryConfig::default();
let q = Query::anchor(3, 0).then(0);
let scores = answer_query::<Product>(&kg, &q, &cfg);
let top = top_k_descending(&scores, 1);
assert_eq!(top[0].0, 0, "two-hop is_a from dog reaches animal");
}
#[test]
fn intersection_keeps_only_shared_answers() {
let kg = taxonomy();
let cfg = QueryConfig::default();
let q = Query::intersection(vec![Query::anchor(3, 0), Query::anchor(4, 0)]);
let scores = answer_query::<Godel>(&kg, &q, &cfg);
let top = top_k_descending(&scores, 1);
assert_eq!(top[0].0, 1, "dog and cat agree on mammal");
}
#[test]
fn union_includes_either_branch() {
let kg = taxonomy();
let cfg = QueryConfig::default();
let q = Query::union(vec![Query::anchor(3, 0), Query::anchor(5, 0)]);
let scores = answer_query::<Godel>(&kg, &q, &cfg);
assert!(scores[1] > 0.5, "mammal in union");
assert!(scores[2] > 0.5, "bird in union");
}
#[test]
fn negation_under_lukasiewicz_is_one_minus() {
let kg = taxonomy();
let cfg = QueryConfig::default();
let q = Query::anchor(3, 0); let pos = answer_query::<Lukasiewicz>(&kg, &q, &cfg);
let neg = answer_query::<Lukasiewicz>(&kg, &q.clone().negate(), &cfg);
for i in 0..6 {
assert!((pos[i] + neg[i] - 1.0).abs() < 1e-5, "involutive at {i}");
}
}
#[test]
fn implication_is_top_where_premise_below_conclusion() {
let kg = taxonomy();
let cfg = QueryConfig::default();
let q = Query::anchor(3, 0).implies(Query::anchor(4, 0));
let scores = answer_query::<Godel>(&kg, &q, &cfg);
assert!((scores[1] - 1.0).abs() < 1e-6, "1.0 → 1.0 = ⊤ at mammal");
}
}