use crate::query::AtomicScorer;
use crate::temporal::TimeSet;
pub struct TemporalPointModel<S> {
pub model: S,
pub temperature: f32,
virtuals: Vec<(usize, TimeSet)>,
}
impl<S: tranz::temporal::TemporalScorer> TemporalPointModel<S> {
pub fn new(model: S) -> Self {
Self::with_temperature(model, 1.0)
}
pub fn with_temperature(model: S, temperature: f32) -> Self {
let temperature = if temperature.is_finite() && temperature > 0.0 {
temperature
} else {
1.0
};
Self {
model,
temperature,
virtuals: Vec::new(),
}
}
pub fn windowed(&mut self, relation: usize, times: TimeSet) -> Option<usize> {
if relation >= self.model.num_relations()
|| times.num_timestamps() != self.model.num_timestamps()
{
return None;
}
self.virtuals.push((relation, times));
Some(self.model.num_relations() + self.virtuals.len() - 1)
}
fn resolve(&self, relation: usize) -> Option<(usize, TimeSet)> {
let n = self.model.num_relations();
if relation < n {
Some((relation, TimeSet::all(self.model.num_timestamps())))
} else {
self.virtuals.get(relation - n).cloned()
}
}
pub fn when(&self, head: usize, relation: usize, tail: usize) -> Vec<f32> {
let n = self.model.num_entities();
if head >= n || tail >= n || relation >= self.model.num_relations() {
return vec![0.0; self.model.num_timestamps()];
}
self.model
.score_all_times(head, relation, tail)
.iter()
.map(|&e| sigmoid(-e / self.temperature))
.collect()
}
}
impl<S: tranz::temporal::TemporalScorer> AtomicScorer for TemporalPointModel<S> {
fn num_entities(&self) -> usize {
self.model.num_entities()
}
fn project(&self, anchor: usize, relation: usize) -> Vec<f32> {
let n = self.model.num_entities();
let Some((base, times)) = self.resolve(relation) else {
return vec![0.0; n]; };
if anchor >= n {
return vec![0.0; n];
}
if times.is_empty() {
return vec![0.0; n]; }
let taus: Vec<usize> = times.iter().collect();
self.model
.score_all_tails_over(anchor, base, &taus)
.iter()
.map(|&e| sigmoid(-e / self.temperature))
.collect()
}
fn project_subset(&self, anchor: usize, relation: usize, candidates: &[usize]) -> Vec<f32> {
let n = self.model.num_entities();
let Some((base, times)) = self.resolve(relation) else {
return vec![0.0; candidates.len()];
};
if anchor >= n || times.is_empty() {
return vec![0.0; candidates.len()];
}
let taus: Vec<usize> = times.iter().collect();
candidates
.iter()
.map(|&tail| {
if tail >= n {
return 0.0;
}
let best = taus
.iter()
.map(|&tau| self.model.score(anchor, base, tail, tau))
.fold(f32::INFINITY, f32::min);
sigmoid(-best / self.temperature)
})
.collect()
}
}
fn sigmoid(x: f32) -> f32 {
if x >= 0.0 {
1.0 / (1.0 + (-x).exp())
} else {
let e = x.exp();
e / (1.0 + e)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{answer_query, Godel, Query, QueryConfig};
struct Planted;
impl tranz::temporal::TemporalScorer for Planted {
fn score(&self, h: usize, _r: usize, t: usize, tau: usize) -> f32 {
match (h, t, tau) {
(0, 1, 1) => -8.0,
(0, 2, 3) => -8.0,
_ => 8.0,
}
}
fn num_entities(&self) -> usize {
3
}
fn num_relations(&self) -> usize {
1
}
fn num_timestamps(&self) -> usize {
4
}
}
#[test]
fn windowed_hops_scope_answers_existentially() {
let mut m = TemporalPointModel::new(Planted);
let early = m.windowed(0, TimeSet::before(2, 4)).unwrap();
let late = m.windowed(0, TimeSet::after(2, 4)).unwrap();
let cfg = QueryConfig::default();
let s = answer_query::<Godel>(&m, &Query::anchor(0, early), &cfg);
assert!(s[1] > 0.99, "entity 1 holds before τ=2: {s:?}");
assert!(s[2] < 0.01, "entity 2 does not: {s:?}");
let s = answer_query::<Godel>(&m, &Query::anchor(0, late), &cfg);
assert!(s[2] > 0.99 && s[1] < 0.01, "{s:?}");
let s = answer_query::<Godel>(&m, &Query::anchor(0, 0), &cfg);
assert!(s[1] > 0.99 && s[2] > 0.99, "{s:?}");
}
#[test]
fn temporal_point_subset_matches_dense_windowed_projection() {
let mut m = TemporalPointModel::new(Planted);
let late = m.windowed(0, TimeSet::after(2, 4)).unwrap();
let dense = m.project(0, late);
assert_eq!(
m.project_subset(0, late, &[2, 1, 99]),
vec![dense[2], dense[1], 0.0]
);
}
#[test]
fn complement_set_hops_work() {
let mut m = TemporalPointModel::new(Planted);
let not_mid = m
.windowed(0, TimeSet::between(2, 2, 4).complement())
.unwrap();
let s = m.project(0, not_mid);
assert!(
s[1] > 0.99 && s[2] > 0.99,
"τ=1 and τ=3 both admitted: {s:?}"
);
}
#[test]
fn invalid_registrations_and_ids_are_safe() {
let mut m = TemporalPointModel::new(Planted);
assert_eq!(m.windowed(5, TimeSet::all(4)), None, "unknown relation");
assert_eq!(m.windowed(0, TimeSet::all(9)), None, "axis mismatch");
let empty = m.windowed(0, TimeSet::empty(4)).unwrap();
assert!(m.project(0, empty).iter().all(|&d| d == 0.0));
assert!(m.project(0, 99).iter().all(|&d| d == 0.0), "unknown id");
assert!(m.project(99, 0).iter().all(|&d| d == 0.0), "bad anchor");
}
#[test]
fn when_composes_into_event_relative_hops() {
let mut m = TemporalPointModel::new(Planted);
let when = m.when(0, 0, 1);
assert_eq!(when.len(), 4);
assert!(when[1] > 0.99, "planted at τ=1: {when:?}");
assert!(when.iter().enumerate().all(|(t, &d)| t == 1 || d < 0.01));
let event = TimeSet::from_degrees(&when, 0.5);
let after_event = m.windowed(0, event.after_all()).unwrap();
let s = m.project(0, after_event);
assert!(s[2] > 0.99, "the τ=3 fact is after the event: {s:?}");
assert!(s[1] < 0.01, "the event fact itself is not: {s:?}");
assert!(m.when(9, 0, 1).iter().all(|&d| d == 0.0));
assert!(m.when(0, 9, 1).iter().all(|&d| d == 0.0));
}
#[test]
fn temperature_spreads_but_preserves_ranking() {
let sharp = TemporalPointModel::new(Planted);
let mut soft = TemporalPointModel::with_temperature(Planted, 5.0);
let _ = soft.windowed(0, TimeSet::all(4));
let a = sharp.project(0, 0);
let b = soft.project(0, 0);
assert!(b[1] < a[1] && b[1] > 0.5, "softened but still positive");
assert!(b[0] > a[0] && b[0] < 0.5);
}
}