use crate::truth::Truth;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RawScoreOrder {
HigherIsBetter,
LowerIsBetter,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RawProjection {
pub scores: Vec<f32>,
pub order: RawScoreOrder,
}
impl RawProjection {
pub fn new(scores: Vec<f32>, order: RawScoreOrder) -> Self {
Self { scores, order }
}
pub fn len(&self) -> usize {
self.scores.len()
}
pub fn is_empty(&self) -> bool {
self.scores.is_empty()
}
pub fn oriented_score(&self, score: f32) -> f32 {
match self.order {
RawScoreOrder::HigherIsBetter => score,
RawScoreOrder::LowerIsBetter => -score,
}
}
pub fn oriented_scores(&self) -> Vec<f32> {
self.scores
.iter()
.map(|&score| self.oriented_score(score))
.collect()
}
}
pub trait AtomicScorer {
fn num_entities(&self) -> usize;
fn project(&self, anchor: usize, relation: usize) -> Vec<f32>;
fn project_batch(&self, anchors: &[usize], relation: usize) -> Vec<Vec<f32>> {
anchors
.iter()
.map(|&anchor| self.project(anchor, relation))
.collect()
}
fn project_raw(&self, _anchor: usize, _relation: usize) -> Option<RawProjection> {
None
}
fn project_raw_batch(&self, anchors: &[usize], relation: usize) -> Option<Vec<RawProjection>> {
let mut batch = Vec::with_capacity(anchors.len());
for &anchor in anchors {
batch.push(self.project_raw(anchor, relation)?);
}
Some(batch)
}
fn project_subset(&self, anchor: usize, relation: usize, candidates: &[usize]) -> Vec<f32> {
let dense = self.project(anchor, relation);
candidates
.iter()
.map(|&i| dense.get(i).copied().unwrap_or(0.0))
.collect()
}
}
#[derive(Debug, Clone)]
pub struct QueryConfig {
pub beam_k: usize,
}
impl Default for QueryConfig {
fn default() -> Self {
Self { beam_k: 128 }
}
}
impl QueryConfig {
pub const fn exact() -> Self {
Self { beam_k: usize::MAX }
}
}
#[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>,
},
Given {
degrees: Vec<f32>,
},
}
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 given(mut degrees: Vec<f32>) -> Self {
for d in &mut degrees {
*d = if d.is_finite() {
d.clamp(0.0, 1.0)
} else {
0.0
};
}
Query::Given { degrees }
}
}
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()
}
Query::Given { degrees } => {
let mut s = degrees.clone();
s.resize(n, 0.0);
s
}
}
}
fn project<T: Truth>(
scorer: &dyn AtomicScorer,
inner_scores: &[f32],
relation: usize,
config: &QueryConfig,
n: usize,
) -> Vec<f32> {
let beam: Vec<_> = top_k_descending(inner_scores, config.beam_k)
.into_iter()
.filter(|&(_, v_score)| v_score > 0.0)
.collect();
let anchors: Vec<_> = beam.iter().map(|(v, _)| *v).collect();
let projections = scorer.project_batch(&anchors, relation);
let mut out = vec![0.0_f32; n];
for ((_, v_score), tails) in beam.iter().zip(projections.iter()) {
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)> {
if k == 0 || scores.is_empty() {
return Vec::new();
}
let mut idx: Vec<(usize, f32)> = scores.iter().copied().enumerate().collect();
if k < idx.len() {
idx.select_nth_unstable_by(k, degree_id_desc_order);
idx.truncate(k);
}
idx.sort_unstable_by(degree_id_desc_order);
idx
}
fn degree_id_desc_order(a: &(usize, f32), b: &(usize, f32)) -> std::cmp::Ordering {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
}
#[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 exact_config_disables_beam_truncation() {
assert_eq!(QueryConfig::exact().beam_k, usize::MAX);
}
#[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");
}
fn implication_at<T: Truth>(p: f32, c: f32) -> f32 {
let mut kg = FuzzyKg::new(2);
if p > 0.0 {
kg.add_edge(0, 0, 1, p);
}
if c > 0.0 {
kg.add_edge(0, 1, 1, c);
}
let q = Query::anchor(0, 0).implies(Query::anchor(0, 1));
answer_query::<T>(&kg, &q, &QueryConfig::default())[1]
}
#[test]
fn implication_is_vacuously_true_when_premise_is_zero() {
assert!((implication_at::<Godel>(0.0, 0.7) - 1.0).abs() < 1e-6);
assert!((implication_at::<Product>(0.0, 0.7) - 1.0).abs() < 1e-6);
assert!((implication_at::<Lukasiewicz>(0.0, 0.7) - 1.0).abs() < 1e-6);
}
#[test]
fn implication_is_top_when_premise_strictly_below_conclusion() {
assert!((implication_at::<Godel>(0.3, 0.8) - 1.0).abs() < 1e-6);
assert!((implication_at::<Product>(0.3, 0.8) - 1.0).abs() < 1e-6);
assert!((implication_at::<Lukasiewicz>(0.3, 0.8) - 1.0).abs() < 1e-6);
}
#[test]
fn implication_pins_each_algebra_when_premise_exceeds_conclusion() {
let (p, c) = (0.8_f32, 0.3_f32);
assert!(
(implication_at::<Godel>(p, c) - c).abs() < 1e-6,
"Godel: a → b = b when a > b"
);
assert!(
(implication_at::<Product>(p, c) - (c / p)).abs() < 1e-6,
"Product: a → b = b / a when a > b"
);
assert!(
(implication_at::<Lukasiewicz>(p, c) - (1.0 - p + c)).abs() < 1e-6,
"Lukasiewicz: a → b = 1 − a + b"
);
}
}