use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::Result;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DerivationKind {
FineTune,
Merge,
Distillation,
Quantization,
Conversion,
Prune,
Custom(String),
}
impl std::fmt::Display for DerivationKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DerivationKind::FineTune => write!(f, "fine-tune"),
DerivationKind::Merge => write!(f, "merge"),
DerivationKind::Distillation => write!(f, "distillation"),
DerivationKind::Quantization => write!(f, "quantization"),
DerivationKind::Conversion => write!(f, "conversion"),
DerivationKind::Prune => write!(f, "prune"),
DerivationKind::Custom(s) => write!(f, "{s}"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineageEdge {
pub parents: Vec<String>,
pub child: String,
pub kind: DerivationKind,
pub notes: BTreeMap<String, String>,
pub created_at: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LineageGraphData {
pub edges: Vec<LineageEdge>,
}
pub struct LineageGraph {
path: PathBuf,
data: LineageGraphData,
}
impl LineageGraph {
const FILE_NAME: &'static str = "lineage_graph.json";
pub fn new(vault_path: &Path) -> Result<Self> {
let path = vault_path.join(Self::FILE_NAME);
let data = if path.exists() {
let contents = fs::read_to_string(&path)?;
serde_json::from_str(&contents).unwrap_or_default()
} else {
LineageGraphData::default()
};
Ok(Self { path, data })
}
fn save(&self) -> Result<()> {
let json = serde_json::to_string_pretty(&self.data)?;
fs::write(&self.path, json)?;
crate::permissions::restrict_file(&self.path)?;
Ok(())
}
pub fn add_edge(&mut self, edge: LineageEdge) -> Result<()> {
self.data.edges.push(edge);
self.save()
}
pub fn edges(&self) -> &[LineageEdge] {
&self.data.edges
}
pub fn nodes(&self) -> BTreeSet<String> {
let mut nodes = BTreeSet::new();
for edge in &self.data.edges {
for p in &edge.parents {
nodes.insert(p.clone());
}
nodes.insert(edge.child.clone());
}
nodes
}
pub fn parents(&self, model: &str) -> Vec<&LineageEdge> {
self.data
.edges
.iter()
.filter(|e| e.child == model)
.collect()
}
pub fn children(&self, model: &str) -> Vec<&LineageEdge> {
self.data
.edges
.iter()
.filter(|e| e.parents.contains(&model.to_string()))
.collect()
}
pub fn ancestors(&self, model: &str) -> Vec<String> {
let mut visited = BTreeSet::new();
let mut queue = vec![model.to_string()];
let mut result = Vec::new();
while let Some(current) = queue.pop() {
for edge in self.parents(¤t) {
for parent in &edge.parents {
if visited.insert(parent.clone()) {
result.push(parent.clone());
queue.push(parent.clone());
}
}
}
}
result
}
pub fn descendants(&self, model: &str) -> Vec<String> {
let mut visited = BTreeSet::new();
let mut queue = vec![model.to_string()];
let mut result = Vec::new();
while let Some(current) = queue.pop() {
for edge in self.children(¤t) {
if visited.insert(edge.child.clone()) {
result.push(edge.child.clone());
queue.push(edge.child.clone());
}
}
}
result
}
pub fn display(&self) -> String {
let mut out = String::new();
if self.data.edges.is_empty() {
out.push_str("No lineage edges recorded.\n");
return out;
}
out.push_str("Cross-model lineage graph:\n");
out.push_str(&format!("{}\n", "─".repeat(50)));
for edge in &self.data.edges {
let parents = edge.parents.join(" + ");
out.push_str(&format!(
" {} --[{}]--> {}\n",
parents, edge.kind, edge.child
));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_edge(parents: &[&str], child: &str, kind: DerivationKind) -> LineageEdge {
LineageEdge {
parents: parents.iter().map(|s| s.to_string()).collect(),
child: child.to_string(),
kind,
notes: BTreeMap::new(),
created_at: "2026-01-01T00:00:00Z".to_string(),
}
}
#[test]
fn test_add_and_query() {
let dir = tempfile::tempdir().unwrap();
let mut graph = LineageGraph::new(dir.path()).unwrap();
graph
.add_edge(make_edge(
&["llama-base"],
"llama-ft",
DerivationKind::FineTune,
))
.unwrap();
graph
.add_edge(make_edge(
&["llama-ft"],
"llama-q4",
DerivationKind::Quantization,
))
.unwrap();
assert_eq!(graph.nodes().len(), 3);
assert_eq!(graph.parents("llama-ft").len(), 1);
assert_eq!(graph.children("llama-ft").len(), 1);
}
#[test]
fn test_ancestors_descendants() {
let dir = tempfile::tempdir().unwrap();
let mut graph = LineageGraph::new(dir.path()).unwrap();
graph
.add_edge(make_edge(&["base"], "ft1", DerivationKind::FineTune))
.unwrap();
graph
.add_edge(make_edge(&["ft1"], "ft2", DerivationKind::FineTune))
.unwrap();
graph
.add_edge(make_edge(&["ft2"], "q4", DerivationKind::Quantization))
.unwrap();
let ancestors = graph.ancestors("q4");
assert!(ancestors.contains(&"ft2".to_string()));
assert!(ancestors.contains(&"ft1".to_string()));
assert!(ancestors.contains(&"base".to_string()));
let descendants = graph.descendants("base");
assert!(descendants.contains(&"ft1".to_string()));
assert!(descendants.contains(&"ft2".to_string()));
assert!(descendants.contains(&"q4".to_string()));
}
#[test]
fn test_merge_parents() {
let dir = tempfile::tempdir().unwrap();
let mut graph = LineageGraph::new(dir.path()).unwrap();
graph
.add_edge(make_edge(
&["model-a", "model-b"],
"merged",
DerivationKind::Merge,
))
.unwrap();
assert_eq!(graph.parents("merged").len(), 1);
assert_eq!(graph.parents("merged")[0].parents.len(), 2);
}
#[test]
fn test_persistence() {
let dir = tempfile::tempdir().unwrap();
{
let mut g = LineageGraph::new(dir.path()).unwrap();
g.add_edge(make_edge(&["a"], "b", DerivationKind::Distillation))
.unwrap();
}
let g2 = LineageGraph::new(dir.path()).unwrap();
assert_eq!(g2.edges().len(), 1);
}
#[test]
fn test_display() {
let dir = tempfile::tempdir().unwrap();
let mut graph = LineageGraph::new(dir.path()).unwrap();
graph
.add_edge(make_edge(&["base"], "child", DerivationKind::FineTune))
.unwrap();
let text = graph.display();
assert!(text.contains("base"));
assert!(text.contains("child"));
}
}