use std::collections::HashMap;
use std::fs::File;
use std::sync::Arc;
use arrow::array::{ArrayRef, Float64Builder, Int32Builder, StringBuilder};
use crate::pipeline::DictValues;
use arrow::datatypes::{DataType, Field, Schema};
use arrow::ipc::writer::FileWriter;
use arrow::record_batch::RecordBatch;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GraphFormat {
Ipc,
Parquet,
}
impl GraphFormat {
pub fn from_str(s: &str) -> GraphFormat {
match s {
"parquet" => GraphFormat::Parquet,
_ => GraphFormat::Ipc,
}
}
}
pub struct NodeWriter {
writer: FileWriter<File>,
schema: Arc<Schema>,
}
impl NodeWriter {
pub fn new(
path: &str,
full_schema: &Schema,
metadata: &HashMap<String, String>,
) -> Result<Self, String> {
let fields: Vec<Field> = full_schema
.fields()
.iter()
.enumerate()
.map(|(i, f)| {
if i == 0 {
Field::new("node_id", f.data_type().clone(), f.is_nullable())
.with_metadata(f.metadata().clone())
} else {
f.as_ref().clone()
}
})
.collect();
let schema = Arc::new(Schema::new(fields).with_metadata(metadata.clone()));
let file = File::create(path).map_err(|e| format!("create node file {path}: {e}"))?;
let writer = FileWriter::try_new(file, &schema)
.map_err(|e| format!("node FileWriter {path}: {e}"))?;
Ok(NodeWriter { writer, schema })
}
pub fn write_batch(&mut self, batch: &RecordBatch) -> Result<(), String> {
let rb = RecordBatch::try_new(self.schema.clone(), batch.columns().to_vec())
.map_err(|e| format!("rebuild node batch: {e}"))?;
self.writer
.write(&rb)
.map_err(|e| format!("write node batch: {e}"))
}
pub fn finish(mut self) -> Result<(), String> {
self.writer
.finish()
.map_err(|e| format!("finish node writer: {e}"))
}
}
fn low_cardinality_dict_type() -> DataType {
DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Utf8))
}
fn edge_schema(metadata: &HashMap<String, String>) -> Arc<Schema> {
Arc::new(
Schema::new(vec![
Field::new("source_node_id", DataType::Utf8, false),
Field::new("target_node_id", DataType::Utf8, false),
Field::new("edge_type", low_cardinality_dict_type(), false),
Field::new("subtype", low_cardinality_dict_type(), false),
Field::new("weight", DataType::Float64, false),
])
.with_metadata(metadata.clone()),
)
}
pub struct EdgeWriter {
writer: FileWriter<File>,
schema: Arc<Schema>,
src_buf: StringBuilder,
tgt_buf: StringBuilder,
etype_dict: DictValues,
etype_keys: Int32Builder,
subtype_dict: DictValues,
subtype_keys: Int32Builder,
weight_buf: Float64Builder,
count: usize,
}
const EDGE_FLUSH: usize = 100_000;
impl EdgeWriter {
pub fn new(
path: &str,
metadata: &HashMap<String, String>,
subtype_dict: DictValues,
) -> Result<Self, String> {
let schema = edge_schema(metadata);
let file = File::create(path).map_err(|e| format!("create edge file {path}: {e}"))?;
let writer = FileWriter::try_new(file, &schema)
.map_err(|e| format!("edge FileWriter {path}: {e}"))?;
Ok(EdgeWriter {
writer,
schema,
src_buf: StringBuilder::new(),
tgt_buf: StringBuilder::new(),
etype_dict: DictValues::new(["fk", "hard_neg", "exact_dup", "fuzzy_dup"]),
etype_keys: Int32Builder::new(),
subtype_dict,
subtype_keys: Int32Builder::new(),
weight_buf: Float64Builder::new(),
count: 0,
})
}
pub fn push(
&mut self,
src: &str,
tgt: &str,
etype: &str,
subtype: &str,
weight: f64,
) -> Result<(), String> {
self.src_buf.append_value(src);
self.tgt_buf.append_value(tgt);
self.etype_keys.append_value(self.etype_dict.key(etype));
self.subtype_keys
.append_value(self.subtype_dict.key(subtype));
self.weight_buf.append_value(weight);
self.count += 1;
if self.count >= EDGE_FLUSH {
self.flush()?;
}
Ok(())
}
fn flush(&mut self) -> Result<(), String> {
if self.count == 0 {
return Ok(());
}
let rb = RecordBatch::try_new(
self.schema.clone(),
vec![
Arc::new(self.src_buf.finish()) as ArrayRef,
Arc::new(self.tgt_buf.finish()) as ArrayRef,
self.etype_dict.finish_keys(self.etype_keys.finish()),
self.subtype_dict.finish_keys(self.subtype_keys.finish()),
Arc::new(self.weight_buf.finish()) as ArrayRef,
],
)
.map_err(|e| format!("build edge batch: {e}"))?;
self.writer
.write(&rb)
.map_err(|e| format!("write edge batch: {e}"))?;
self.count = 0;
Ok(())
}
pub fn finish(mut self) -> Result<(), String> {
self.flush()?;
self.writer
.finish()
.map_err(|e| format!("finish edge writer: {e}"))
}
}
fn pair_edge_type(a_identical: bool, b_identical: bool) -> &'static str {
if a_identical && b_identical {
"exact_dup"
} else {
"fuzzy_dup"
}
}
pub fn push_dup_clusters(
ew: &mut EdgeWriter,
clusters: &crate::gt::ClusterCsr,
max_edges: usize,
) -> Result<(), String> {
for (records, idents) in clusters.groups() {
let k = records.len();
if k < 2 {
continue;
}
let n_edges = k * (k - 1) / 2;
if n_edges > max_edges {
log::warn!(
"dup cluster has {n_edges} edges > {max_edges}, using spanning tree fallback"
);
for i in 0..k - 1 {
let etype = pair_edge_type(idents.get(i), idents.get(i + 1));
let src = crate::pipeline::record_id_string(records[i] as usize);
let tgt = crate::pipeline::record_id_string(records[i + 1] as usize);
ew.push(&src, &tgt, etype, "spanning_tree", 1.0)?;
}
} else {
for i in 0..k {
let src = crate::pipeline::record_id_string(records[i] as usize);
#[allow(clippy::needless_range_loop)]
for j in (i + 1)..k {
let etype = pair_edge_type(idents.get(i), idents.get(j));
let tgt = crate::pipeline::record_id_string(records[j] as usize);
ew.push(&src, &tgt, etype, "complete", 1.0)?;
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use arrow::array::AsArray;
use std::collections::HashSet;
fn read_edges(path: &str) -> Vec<(String, String, String, String, f64)> {
let file = File::open(path).unwrap();
let reader = arrow::ipc::reader::FileReader::try_new(file, None).unwrap();
let mut out = Vec::new();
for b in reader {
let b = b.unwrap();
let src = b.column(0).as_string::<i32>();
let tgt = b.column(1).as_string::<i32>();
let et_col = arrow::compute::cast(b.column(2), &DataType::Utf8).unwrap();
let st_col = arrow::compute::cast(b.column(3), &DataType::Utf8).unwrap();
let et = et_col.as_string::<i32>();
let st = st_col.as_string::<i32>();
let w = b.column(4).as_primitive::<arrow::datatypes::Float64Type>();
for i in 0..b.num_rows() {
out.push((
src.value(i).to_string(),
tgt.value(i).to_string(),
et.value(i).to_string(),
st.value(i).to_string(),
w.value(i),
));
}
}
out
}
fn temp_path(name: &str) -> String {
let mut p = std::env::temp_dir();
p.push(format!("dupehell_test_{}_{}", name, std::process::id()));
p.to_string_lossy().to_string()
}
fn rid(i: usize) -> String {
crate::pipeline::record_id_string(i)
}
fn one_cluster(master_local_idx: usize, members: Vec<(usize, bool)>) -> crate::gt::ClusterCsr {
let master_key = crate::pipeline::pack_master_key(&format!(
"{}-{}",
crate::pipeline::entity_prefix(0),
crate::pipeline::pad_string(master_local_idx)
))
.unwrap();
let pairs = members
.into_iter()
.map(|(ridx, ident)| {
(
master_key,
crate::gt::pack_ridx_identical(ridx as u64, ident),
)
})
.collect();
crate::gt::ClusterCsr::build(pairs)
}
#[test]
fn push_dup_clusters_complete() {
let path = temp_path("edges_complete.ipc");
let _ = std::fs::remove_file(&path);
let mut ew = EdgeWriter::new(
&path,
&HashMap::new(),
DictValues::new(["complete", "spanning_tree"]),
)
.unwrap();
let clusters = one_cluster(1, vec![(1, true), (2, true), (3, true), (4, true)]);
push_dup_clusters(&mut ew, &clusters, 10_000).unwrap();
ew.finish().unwrap();
let edges = read_edges(&path);
assert_eq!(edges.len(), 6, "4 records -> 6 complete edges");
assert!(
edges
.iter()
.all(|e| e.2 == "exact_dup" && e.3 == "complete")
);
assert!(edges.iter().all(|e| (e.4 - 1.0).abs() < 1e-9));
let _ = std::fs::remove_file(&path);
}
#[test]
fn push_dup_clusters_fuzzy_edge_type() {
let path = temp_path("edges_fuzzy.ipc");
let _ = std::fs::remove_file(&path);
let mut ew = EdgeWriter::new(
&path,
&HashMap::new(),
DictValues::new(["complete", "spanning_tree"]),
)
.unwrap();
let clusters = one_cluster(1, vec![(1, true), (2, true), (3, false)]);
push_dup_clusters(&mut ew, &clusters, 10_000).unwrap();
ew.finish().unwrap();
let edges = read_edges(&path);
assert_eq!(edges.len(), 3);
let by_pair: HashMap<(String, String), String> = edges
.iter()
.map(|e| ((e.0.clone(), e.1.clone()), e.2.clone()))
.collect();
assert_eq!(by_pair[&(rid(1), rid(2))], "exact_dup");
assert_eq!(by_pair[&(rid(1), rid(3))], "fuzzy_dup");
assert_eq!(by_pair[&(rid(2), rid(3))], "fuzzy_dup");
let _ = std::fs::remove_file(&path);
}
#[test]
fn push_dup_clusters_spanning_tree() {
let path = temp_path("edges_spanning.ipc");
let _ = std::fs::remove_file(&path);
let mut ew = EdgeWriter::new(
&path,
&HashMap::new(),
DictValues::new(["complete", "spanning_tree"]),
)
.unwrap();
let members: Vec<(usize, bool)> = (0..200).map(|i| (i, true)).collect();
let clusters = one_cluster(1, members.clone());
push_dup_clusters(&mut ew, &clusters, 10_000).unwrap();
ew.finish().unwrap();
let edges = read_edges(&path);
assert_eq!(
edges.len(),
199,
"200-record cluster -> 199 spanning-tree edges"
);
assert!(
edges
.iter()
.all(|e| e.2 == "exact_dup" && e.3 == "spanning_tree")
);
let got: HashSet<(String, String)> =
edges.iter().map(|e| (e.0.clone(), e.1.clone())).collect();
for w in members.windows(2) {
assert!(got.contains(&(rid(w[0].0), rid(w[1].0))));
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn push_dup_clusters_skips_singletons() {
let path = temp_path("edges_singleton.ipc");
let _ = std::fs::remove_file(&path);
let mut ew = EdgeWriter::new(
&path,
&HashMap::new(),
DictValues::new(["complete", "spanning_tree"]),
)
.unwrap();
let clusters = one_cluster(1, vec![(1, true)]);
push_dup_clusters(&mut ew, &clusters, 10_000).unwrap();
ew.finish().unwrap();
assert!(read_edges(&path).is_empty());
let _ = std::fs::remove_file(&path);
}
}