use crate::AletheiaDB;
use crate::core::error::{Error, Result, VectorError};
use crate::core::id::NodeId;
use crate::core::temporal::{TimeRange, Timestamp};
use crate::core::vector::ops::{dot_product, magnitude, normalize};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct EvolutionPoint {
pub timestamp: Timestamp,
pub scores: HashMap<String, f32>,
}
#[derive(Debug, Clone)]
struct Axis {
name: String,
vector: Vec<f32>,
}
pub struct Prism<'a> {
db: &'a AletheiaDB,
axes: Vec<Axis>,
vector_property: Option<String>,
}
impl<'a> Prism<'a> {
pub fn new(db: &'a AletheiaDB) -> Self {
Self {
db,
axes: Vec::new(),
vector_property: None,
}
}
pub fn with_vector_property(mut self, property: &str) -> Self {
self.vector_property = Some(property.to_string());
self
}
fn resolve_node_vector<'n>(
&self,
node_id: NodeId,
properties: &'n crate::core::property::PropertyMap,
explicit_property: Option<&str>,
context: &str,
) -> Result<&'n [f32]> {
let selected_property = explicit_property.or(self.vector_property.as_deref());
if let Some(property) = selected_property {
let value = properties.get(property).ok_or_else(|| {
Error::Vector(VectorError::IndexError(format!(
"Node {} is missing vector property '{}'",
node_id, property
)))
})?;
return value.as_vector().ok_or_else(|| {
Error::Vector(VectorError::IndexError(format!(
"Node {} property '{}' is not a dense vector",
node_id, property
)))
});
}
let mut first_vector: Option<&[f32]> = None;
let mut vector_keys = Vec::new();
for (key, value) in properties.iter() {
if let Some(vector) = value.as_vector() {
if first_vector.is_none() {
first_vector = Some(vector);
}
vector_keys.push(key.to_string());
}
}
match vector_keys.len() {
0 => Err(Error::Vector(VectorError::IndexError(format!(
"Node {} has no vector properties to {}",
node_id, context
)))),
1 => match first_vector {
Some(vector) => Ok(vector),
None => unreachable!("vector_keys length and first_vector state diverged"),
},
_ => {
vector_keys.sort();
Err(Error::Vector(VectorError::IndexError(format!(
"Node {} has multiple vector properties [{}]. Configure Prism with \
with_vector_property(...) or use *_with_property APIs for deterministic selection.",
node_id,
vector_keys.join(", ")
))))
}
}
}
pub fn add_axis(&mut self, name: &str, vector: Vec<f32>) {
let normalized = normalize(&vector);
self.axes.push(Axis {
name: name.to_string(),
vector: normalized,
});
}
pub fn add_axis_from_node(&mut self, name: &str, node_id: NodeId) -> Result<()> {
let node = self.db.get_node(node_id)?;
let vector = self.resolve_node_vector(node_id, &node.properties, None, "use as an axis")?;
self.add_axis(name, vector.to_vec());
Ok(())
}
pub fn add_axis_from_node_with_property(
&mut self,
name: &str,
node_id: NodeId,
property: &str,
) -> Result<()> {
let node = self.db.get_node(node_id)?;
let vector =
self.resolve_node_vector(node_id, &node.properties, Some(property), "use as an axis")?;
self.add_axis(name, vector.to_vec());
Ok(())
}
pub fn orthogonalize(&mut self) {
let mut ortho_axes: Vec<Axis> = Vec::with_capacity(self.axes.len());
for axis in &self.axes {
let mut v = axis.vector.clone();
for u_axis in &ortho_axes {
let u = &u_axis.vector;
let dot = dot_product(&v, u).unwrap_or(0.0);
for (vi, ui) in v.iter_mut().zip(u.iter()) {
*vi -= dot * ui;
}
}
if magnitude(&v) > 1e-6 {
let normalized = normalize(&v);
ortho_axes.push(Axis {
name: axis.name.clone(),
vector: normalized,
});
} else {
}
}
self.axes = ortho_axes;
}
pub fn analyze(&self, target: &[f32]) -> Result<HashMap<String, f32>> {
let mut spectrum = HashMap::new();
for axis in &self.axes {
if axis.vector.len() != target.len() {
return Err(Error::Vector(VectorError::DimensionMismatch {
expected: axis.vector.len(),
actual: target.len(),
}));
}
let score = dot_product(target, &axis.vector)?;
spectrum.insert(axis.name.clone(), score);
}
Ok(spectrum)
}
pub fn analyze_node(&self, node_id: NodeId) -> Result<HashMap<String, f32>> {
let node = self.db.get_node(node_id)?;
let vector = self.resolve_node_vector(node_id, &node.properties, None, "analyze")?;
self.analyze(vector)
}
pub fn analyze_node_with_property(
&self,
node_id: NodeId,
property: &str,
) -> Result<HashMap<String, f32>> {
let node = self.db.get_node(node_id)?;
let vector =
self.resolve_node_vector(node_id, &node.properties, Some(property), "analyze")?;
self.analyze(vector)
}
pub fn analyze_evolution(
&self,
node_id: NodeId,
time_range: TimeRange,
) -> Result<Vec<EvolutionPoint>> {
let history = self.db.get_node_history(node_id)?;
let mut points = Vec::new();
for version in history.versions {
if version.temporal.valid_time().overlaps(&time_range) {
if let Ok(vector) = self.resolve_node_vector(
node_id,
&version.properties,
None,
"analyze evolution",
) {
let scores = self.analyze(vector)?;
points.push(EvolutionPoint {
timestamp: version.temporal.valid_time().start(),
scores,
});
}
}
}
Ok(points)
}
pub fn residual(&self, target: &[f32]) -> Result<f32> {
let mut reconstruction = vec![0.0; target.len()];
for axis in &self.axes {
let score = dot_product(target, &axis.vector)?;
for (r, a) in reconstruction.iter_mut().zip(axis.vector.iter()) {
*r += score * a;
}
}
let diff: Vec<f32> = target
.iter()
.zip(reconstruction.iter())
.map(|(t, r)| t - r)
.collect();
Ok(magnitude(&diff))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::property::PropertyMapBuilder;
use crate::index::vector::{DistanceMetric, HnswConfig};
#[test]
fn test_prism_analysis() {
let db = AletheiaDB::new().unwrap();
let config = HnswConfig::new(2, DistanceMetric::Cosine);
db.enable_vector_index("embedding", config).unwrap();
let mut prism = Prism::new(&db);
prism.add_axis("Logic", vec![1.0, 0.0]);
prism.add_axis("Emotion", vec![0.0, 1.0]);
let target = vec![1.0, 1.0];
let spectrum = prism.analyze(&target).unwrap();
assert!((spectrum.get("Logic").unwrap() - 1.0).abs() < 1e-5);
assert!((spectrum.get("Emotion").unwrap() - 1.0).abs() < 1e-5);
}
#[test]
fn test_prism_orthogonalization() {
let db = AletheiaDB::new().unwrap();
let mut prism = Prism::new(&db);
prism.add_axis("X", vec![1.0, 0.0]);
prism.add_axis("Diag", vec![1.0, 1.0]);
prism.orthogonalize();
let x_axis = prism.axes.iter().find(|a| a.name == "X").unwrap();
assert!((x_axis.vector[0] - 1.0).abs() < 1e-5);
assert!((x_axis.vector[1] - 0.0).abs() < 1e-5);
let diag_axis = prism.axes.iter().find(|a| a.name == "Diag").unwrap();
assert!((diag_axis.vector[0] - 0.0).abs() < 1e-5);
assert!((diag_axis.vector[1] - 1.0).abs() < 1e-5);
}
#[test]
fn test_prism_residual() {
let db = AletheiaDB::new().unwrap();
let mut prism = Prism::new(&db);
prism.add_axis("X", vec![1.0, 0.0]);
let res = prism.residual(&[1.0, 1.0]).unwrap();
assert!((res - 1.0).abs() < 1e-5);
}
#[test]
fn test_prism_from_node() {
let db = AletheiaDB::new().unwrap();
let config = HnswConfig::new(2, DistanceMetric::Cosine);
db.enable_vector_index("vec", config).unwrap();
let props = PropertyMapBuilder::new()
.insert_vector("vec", &[1.0, 0.0])
.build();
let node = db.create_node("Basis", props).unwrap();
let mut prism = Prism::new(&db);
prism.add_axis_from_node("BasisNode", node).unwrap();
let spectrum = prism.analyze(&[1.0, 0.0]).unwrap();
assert!((spectrum.get("BasisNode").unwrap() - 1.0).abs() < 1e-5);
}
#[test]
fn test_prism_rejects_ambiguous_vector_selection() {
let db = AletheiaDB::new().unwrap();
let props = PropertyMapBuilder::new()
.insert_vector("text_vec", &[1.0, 0.0])
.insert_vector("image_vec", &[0.0, 1.0])
.build();
let node = db.create_node("Doc", props).unwrap();
let mut prism = Prism::new(&db);
let err = prism.add_axis_from_node("DocAxis", node).unwrap_err();
let err_msg = format!("{err}");
assert!(err_msg.contains("multiple vector properties"));
assert!(err_msg.contains("image_vec"));
assert!(err_msg.contains("text_vec"));
}
#[test]
fn test_prism_explicit_property_selection_is_deterministic() {
let db = AletheiaDB::new().unwrap();
let axis_props = PropertyMapBuilder::new()
.insert_vector("text_vec", &[1.0, 0.0])
.insert_vector("image_vec", &[0.0, 1.0])
.build();
let axis_node = db.create_node("Axis", axis_props).unwrap();
let target_props = PropertyMapBuilder::new()
.insert_vector("text_vec", &[1.0, 0.0])
.insert_vector("image_vec", &[0.0, 1.0])
.build();
let target_node = db.create_node("Target", target_props).unwrap();
let mut prism = Prism::new(&db).with_vector_property("text_vec");
prism.add_axis_from_node("TextAxis", axis_node).unwrap();
let spectrum = prism.analyze_node(target_node).unwrap();
assert!((spectrum.get("TextAxis").unwrap() - 1.0).abs() < 1e-5);
let mut by_property = Prism::new(&db);
by_property
.add_axis_from_node_with_property("ImageAxis", axis_node, "image_vec")
.unwrap();
let image_spectrum = by_property
.analyze_node_with_property(target_node, "image_vec")
.unwrap();
assert!((image_spectrum.get("ImageAxis").unwrap() - 1.0).abs() < 1e-5);
}
#[test]
fn test_prism_analyze_evolution() {
use crate::api::transaction::WriteOps;
use crate::core::temporal::{TimeRange, time};
let db = AletheiaDB::new().unwrap();
let config = HnswConfig::new(2, DistanceMetric::Cosine);
db.enable_vector_index("vec", config).unwrap();
let t1 = time::from_millis(1000);
let t2 = time::from_millis(2000);
let props1 = PropertyMapBuilder::new()
.insert_vector("vec", &[1.0, 0.0])
.build();
let node_id = db
.write(|tx| tx.create_node_with_valid_time("Concept", props1, Some(t1)))
.unwrap();
let props2 = PropertyMapBuilder::new()
.insert_vector("vec", &[0.0, 1.0])
.build();
db.write(|tx| tx.update_node_with_valid_time(node_id, props2, Some(t2)))
.unwrap();
let mut prism = Prism::new(&db).with_vector_property("vec");
prism.add_axis("Logic", vec![1.0, 0.0]);
prism.add_axis("Emotion", vec![0.0, 1.0]);
let range = TimeRange::new(time::from_millis(0), time::from_millis(3000)).unwrap();
let points = prism.analyze_evolution(node_id, range).unwrap();
assert_eq!(points.len(), 2);
let p1 = &points[0];
assert_eq!(p1.timestamp, t1);
assert!((p1.scores.get("Logic").unwrap() - 1.0).abs() < 1e-5);
assert!((p1.scores.get("Emotion").unwrap() - 0.0).abs() < 1e-5);
let p2 = &points[1];
assert_eq!(p2.timestamp, t2);
assert!((p2.scores.get("Logic").unwrap() - 0.0).abs() < 1e-5);
assert!((p2.scores.get("Emotion").unwrap() - 1.0).abs() < 1e-5);
}
}