use crate::VectorIndex;
use ipfrs_core::{Cid, Error, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ModelVersion {
pub major: u32,
pub minor: u32,
pub patch: u32,
pub tag: Option<String>,
}
impl ModelVersion {
pub fn new(major: u32, minor: u32, patch: u32) -> Self {
Self {
major,
minor,
patch,
tag: None,
}
}
pub fn with_tag(mut self, tag: String) -> Self {
self.tag = Some(tag);
self
}
pub fn is_compatible_with(&self, other: &ModelVersion) -> bool {
self.major == other.major
}
}
impl std::fmt::Display for ModelVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
if let Some(tag) = &self.tag {
write!(f, "-{}", tag)?;
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingTransform {
pub from_version: ModelVersion,
pub to_version: ModelVersion,
pub transform_matrix: Option<Vec<Vec<f32>>>,
pub bias: Option<Vec<f32>>,
}
impl EmbeddingTransform {
pub fn identity(version: ModelVersion) -> Self {
Self {
from_version: version.clone(),
to_version: version,
transform_matrix: None,
bias: None,
}
}
pub fn linear(
from_version: ModelVersion,
to_version: ModelVersion,
matrix: Vec<Vec<f32>>,
) -> Self {
Self {
from_version,
to_version,
transform_matrix: Some(matrix),
bias: None,
}
}
pub fn apply(&self, embedding: &[f32]) -> Vec<f32> {
let mut result = embedding.to_vec();
if let Some(matrix) = &self.transform_matrix {
let out_dim = matrix[0].len();
let mut transformed = vec![0.0; out_dim];
for (i, row) in matrix.iter().enumerate() {
if i >= embedding.len() {
break;
}
for (j, &val) in row.iter().enumerate() {
transformed[j] += embedding[i] * val;
}
}
result = transformed;
}
if let Some(bias) = &self.bias {
for (i, &b) in bias.iter().enumerate() {
if i < result.len() {
result[i] += b;
}
}
}
result
}
}
pub struct DynamicIndex {
indices: Arc<RwLock<HashMap<ModelVersion, VectorIndex>>>,
active_version: Arc<RwLock<ModelVersion>>,
transforms: Arc<RwLock<HashMap<(ModelVersion, ModelVersion), EmbeddingTransform>>>,
dimension: usize,
}
impl DynamicIndex {
pub fn new(initial_version: ModelVersion, dimension: usize) -> Result<Self> {
let mut indices = HashMap::new();
let index = VectorIndex::with_defaults(dimension)?;
indices.insert(initial_version.clone(), index);
Ok(Self {
indices: Arc::new(RwLock::new(indices)),
active_version: Arc::new(RwLock::new(initial_version)),
transforms: Arc::new(RwLock::new(HashMap::new())),
dimension,
})
}
pub fn active_version(&self) -> ModelVersion {
self.active_version
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
pub fn add_version(
&self,
version: ModelVersion,
transform: Option<EmbeddingTransform>,
) -> Result<()> {
let mut indices = self.indices.write().unwrap_or_else(|e| e.into_inner());
if indices.contains_key(&version) {
return Err(Error::InvalidInput(format!(
"Version {} already exists",
version
)));
}
let index = VectorIndex::with_defaults(self.dimension)?;
indices.insert(version.clone(), index);
if let Some(t) = transform {
let mut transforms = self.transforms.write().unwrap_or_else(|e| e.into_inner());
transforms.insert((t.from_version.clone(), t.to_version.clone()), t);
}
Ok(())
}
pub fn set_active_version(&self, version: ModelVersion) -> Result<()> {
let indices = self.indices.read().unwrap_or_else(|e| e.into_inner());
if !indices.contains_key(&version) {
return Err(Error::InvalidInput(format!(
"Version {} does not exist",
version
)));
}
let mut active = self
.active_version
.write()
.unwrap_or_else(|e| e.into_inner());
*active = version;
Ok(())
}
pub fn insert(
&self,
cid: &Cid,
embedding: &[f32],
version: Option<ModelVersion>,
) -> Result<()> {
let version = version.unwrap_or_else(|| self.active_version());
let mut indices = self.indices.write().unwrap_or_else(|e| e.into_inner());
let index = indices
.get_mut(&version)
.ok_or_else(|| Error::InvalidInput(format!("Version {} does not exist", version)))?;
index.insert(cid, embedding)?;
Ok(())
}
pub fn update(
&self,
cid: &Cid,
new_embedding: &[f32],
version: Option<ModelVersion>,
) -> Result<()> {
let version = version.unwrap_or_else(|| self.active_version());
let mut indices = self.indices.write().unwrap_or_else(|e| e.into_inner());
let index = indices
.get_mut(&version)
.ok_or_else(|| Error::InvalidInput(format!("Version {} does not exist", version)))?;
index.delete(cid)?;
index.insert(cid, new_embedding)?;
Ok(())
}
pub fn migrate(&self, from: &ModelVersion, to: &ModelVersion) -> Result<usize> {
let transforms = self.transforms.read().unwrap_or_else(|e| e.into_inner());
let transform = transforms
.get(&(from.clone(), to.clone()))
.ok_or_else(|| Error::InvalidInput(format!("No transform from {} to {}", from, to)))?
.clone();
drop(transforms);
let indices = self.indices.read().unwrap_or_else(|e| e.into_inner());
let source_index = indices.get(from).ok_or_else(|| {
Error::InvalidInput(format!("Source version {} does not exist", from))
})?;
if !indices.contains_key(to) {
return Err(Error::InvalidInput(format!(
"Target version {} does not exist",
to
)));
}
let embeddings = source_index.get_all_embeddings();
drop(indices);
let mut migrated_count = 0;
for (cid, embedding) in embeddings {
let transformed = transform.apply(&embedding);
let mut indices = self.indices.write().unwrap_or_else(|e| e.into_inner());
if let Some(target_index) = indices.get_mut(to) {
if !target_index.contains(&cid) {
target_index.insert(&cid, &transformed)?;
migrated_count += 1;
}
}
drop(indices);
}
Ok(migrated_count)
}
pub fn version_stats(&self) -> HashMap<ModelVersion, VersionStats> {
let indices = self.indices.read().unwrap_or_else(|e| e.into_inner());
indices
.iter()
.map(|(version, index)| {
let stats = VersionStats {
version: version.clone(),
num_embeddings: index.len(),
is_active: version == &self.active_version(),
};
(version.clone(), stats)
})
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionStats {
pub version: ModelVersion,
pub num_embeddings: usize,
pub is_active: bool,
}
pub struct OnlineUpdater {
learning_rate: f32,
momentum: f32,
velocity: Arc<RwLock<HashMap<Cid, Vec<f32>>>>,
}
impl OnlineUpdater {
pub fn new(learning_rate: f32, momentum: f32) -> Self {
Self {
learning_rate,
momentum,
velocity: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn update(&self, cid: &Cid, embedding: &[f32], gradient: &[f32]) -> Vec<f32> {
let mut velocity = self.velocity.write().unwrap_or_else(|e| e.into_inner());
let v = velocity
.entry(*cid)
.or_insert_with(|| vec![0.0; embedding.len()]);
for i in 0..embedding.len().min(gradient.len()) {
v[i] = self.momentum * v[i] - self.learning_rate * gradient[i];
}
embedding
.iter()
.zip(v.iter())
.map(|(&e, &vel)| e + vel)
.collect()
}
pub fn reset(&self) {
let mut velocity = self.velocity.write().unwrap_or_else(|e| e.into_inner());
velocity.clear();
}
pub fn stats(&self) -> OnlineUpdaterStats {
let velocity = self.velocity.read().unwrap_or_else(|e| e.into_inner());
OnlineUpdaterStats {
learning_rate: self.learning_rate,
momentum: self.momentum,
num_tracked: velocity.len(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OnlineUpdaterStats {
pub learning_rate: f32,
pub momentum: f32,
pub num_tracked: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_model_version() {
let v1 = ModelVersion::new(1, 0, 0);
let v2 = ModelVersion::new(1, 1, 0);
let v3 = ModelVersion::new(2, 0, 0);
assert!(v1.is_compatible_with(&v2));
assert!(!v1.is_compatible_with(&v3));
assert_eq!(v1.to_string(), "1.0.0");
assert_eq!(v1.with_tag("alpha".into()).to_string(), "1.0.0-alpha");
}
#[test]
fn test_embedding_transform() {
let v1 = ModelVersion::new(1, 0, 0);
let v2 = ModelVersion::new(1, 1, 0);
let identity = EmbeddingTransform::identity(v1.clone());
let embedding = vec![1.0, 2.0, 3.0];
let result = identity.apply(&embedding);
assert_eq!(result, embedding);
let matrix = vec![vec![1.0, 0.0], vec![0.0, 2.0]];
let transform = EmbeddingTransform::linear(v1, v2, matrix);
let embedding = vec![1.0, 2.0];
let result = transform.apply(&embedding);
assert_eq!(result, vec![1.0, 4.0]);
}
#[test]
fn test_dynamic_index_creation() {
let version = ModelVersion::new(1, 0, 0);
let index = DynamicIndex::new(version.clone(), 128)
.expect("test: DynamicIndex creation should succeed");
assert_eq!(index.active_version(), version);
}
#[test]
fn test_add_version() {
let v1 = ModelVersion::new(1, 0, 0);
let v2 = ModelVersion::new(1, 1, 0);
let index =
DynamicIndex::new(v1.clone(), 128).expect("test: DynamicIndex creation should succeed");
index
.add_version(v2.clone(), None)
.expect("test: add_version should succeed");
let stats = index.version_stats();
assert_eq!(stats.len(), 2);
assert!(stats.contains_key(&v1));
assert!(stats.contains_key(&v2));
}
#[test]
fn test_set_active_version() {
let v1 = ModelVersion::new(1, 0, 0);
let v2 = ModelVersion::new(1, 1, 0);
let index =
DynamicIndex::new(v1.clone(), 128).expect("test: DynamicIndex creation should succeed");
index
.add_version(v2.clone(), None)
.expect("test: add_version should succeed");
assert_eq!(index.active_version(), v1);
index
.set_active_version(v2.clone())
.expect("test: set_active_version should succeed");
assert_eq!(index.active_version(), v2);
}
#[test]
fn test_insert_and_update() {
use multihash_codetable::{Code, MultihashDigest};
let version = ModelVersion::new(1, 0, 0);
let index =
DynamicIndex::new(version, 3).expect("test: DynamicIndex creation should succeed");
let data = "test_embedding";
let hash = Code::Sha2_256.digest(data.as_bytes());
let cid = Cid::new_v1(0x55, hash);
let embedding = vec![1.0, 2.0, 3.0];
index
.insert(&cid, &embedding, None)
.expect("test: insert should succeed");
let stats = index.version_stats();
assert_eq!(
stats
.values()
.next()
.expect("test: stats should have at least one entry")
.num_embeddings,
1
);
let new_embedding = vec![4.0, 5.0, 6.0];
index
.update(&cid, &new_embedding, None)
.expect("test: update should succeed");
let stats = index.version_stats();
assert_eq!(
stats
.values()
.next()
.expect("test: stats should have at least one entry")
.num_embeddings,
1
);
}
#[test]
fn test_online_updater() {
use multihash_codetable::{Code, MultihashDigest};
let updater = OnlineUpdater::new(0.1, 0.9);
let data = "test";
let hash = Code::Sha2_256.digest(data.as_bytes());
let cid = Cid::new_v1(0x55, hash);
let embedding = vec![1.0, 1.0, 1.0];
let gradient = vec![0.1, 0.1, 0.1];
let updated = updater.update(&cid, &embedding, &gradient);
assert!(updated[0] < 1.0);
assert_eq!(updated.len(), 3);
let stats = updater.stats();
assert_eq!(stats.num_tracked, 1);
}
#[test]
fn test_updater_momentum() {
use multihash_codetable::{Code, MultihashDigest};
let updater = OnlineUpdater::new(0.1, 0.9);
let data = "test";
let hash = Code::Sha2_256.digest(data.as_bytes());
let cid = Cid::new_v1(0x55, hash);
let embedding = vec![1.0];
let gradient = vec![0.1];
let updated1 = updater.update(&cid, &embedding, &gradient);
let updated2 = updater.update(&cid, &updated1, &gradient);
let delta1 = (embedding[0] - updated1[0]).abs();
let delta2 = (updated1[0] - updated2[0]).abs();
assert!(delta2 > delta1);
}
}