use ruvector_mincut::{
DynamicMinCut, MinCutBuilder, MinCutResult, Weight,
};
use serde::{Deserialize, Serialize};
use crate::error::{NqedError, Result};
use crate::graph::{DetectorGraph, DetectorId};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FeatureConfig {
pub compute_global_cut: bool,
pub compute_local_cuts: bool,
pub compute_conductance: bool,
pub normalize: bool,
pub approximation_epsilon: Option<f64>,
}
impl Default for FeatureConfig {
fn default() -> Self {
Self {
compute_global_cut: true,
compute_local_cuts: true,
compute_conductance: true,
normalize: true,
approximation_epsilon: None,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StructuralFeatures {
pub global_min_cut: f64,
pub partition: Option<(Vec<usize>, Vec<usize>)>,
pub cut_edges: Option<Vec<(usize, usize)>>,
pub local_cuts: Vec<f64>,
pub conductance: f64,
pub avg_weighted_degree: f64,
pub spectral_gap: f64,
pub centrality: Vec<f64>,
pub cluster_assignment: Vec<usize>,
pub node_features: Vec<Vec<f32>>,
}
impl Default for StructuralFeatures {
fn default() -> Self {
Self {
global_min_cut: f64::INFINITY,
partition: None,
cut_edges: None,
local_cuts: Vec::new(),
conductance: 0.0,
avg_weighted_degree: 0.0,
spectral_gap: 0.0,
centrality: Vec::new(),
cluster_assignment: Vec::new(),
node_features: Vec::new(),
}
}
}
impl StructuralFeatures {
#[inline]
#[must_use]
pub fn feature_dim(&self) -> usize {
if self.node_features.is_empty() {
0
} else {
self.node_features[0].len()
}
}
#[inline]
#[must_use]
pub fn is_disconnected(&self) -> bool {
self.global_min_cut == 0.0
}
#[inline]
#[must_use]
pub fn node_count(&self) -> usize {
self.local_cuts.len()
}
}
#[derive(Clone, Debug)]
pub struct StructuralFeatureExtractor {
config: FeatureConfig,
}
impl StructuralFeatureExtractor {
#[must_use]
pub fn new(config: FeatureConfig) -> Self {
Self { config }
}
pub fn extract(&self, graph: &DetectorGraph) -> Result<StructuralFeatures> {
let node_count = graph.node_count();
if node_count == 0 {
return Ok(StructuralFeatures::default());
}
let edges = self.graph_to_edges(graph)?;
let mut mincut = if let Some(eps) = self.config.approximation_epsilon {
MinCutBuilder::new()
.approximate(eps)
.with_edges(edges.clone())
.build()
.map_err(|e| NqedError::MinCutError(e.to_string()))?
} else {
MinCutBuilder::new()
.exact()
.with_edges(edges.clone())
.build()
.map_err(|e| NqedError::MinCutError(e.to_string()))?
};
let global_result = if self.config.compute_global_cut {
mincut.min_cut()
} else {
MinCutResult {
value: f64::INFINITY,
cut_edges: None,
partition: None,
is_exact: true,
approximation_ratio: 1.0,
}
};
let local_cuts = if self.config.compute_local_cuts {
self.compute_local_cuts(graph, &edges)?
} else {
vec![0.0; node_count]
};
let conductance = if self.config.compute_conductance {
self.compute_conductance(&edges, node_count)
} else {
0.0
};
let avg_weighted_degree = self.compute_avg_weighted_degree(&edges, node_count);
let centrality = self.compute_centrality(graph, &edges);
let spectral_gap = self.estimate_spectral_gap(conductance);
let cluster_assignment = self.compute_clusters(&global_result, node_count);
let node_features = self.build_node_features(
graph,
&local_cuts,
¢rality,
&cluster_assignment,
conductance,
)?;
let mut features = StructuralFeatures {
global_min_cut: global_result.value,
partition: global_result.partition.map(|(s, t)| {
(s.into_iter().map(|v| v as usize).collect(),
t.into_iter().map(|v| v as usize).collect())
}),
cut_edges: global_result.cut_edges.map(|edges| {
edges.into_iter().map(|(u, v)| (u as usize, v as usize)).collect()
}),
local_cuts,
conductance,
avg_weighted_degree,
spectral_gap,
centrality,
cluster_assignment,
node_features,
};
if self.config.normalize {
self.normalize_features(&mut features);
}
Ok(features)
}
fn graph_to_edges(&self, graph: &DetectorGraph) -> Result<Vec<(u64, u64, Weight)>> {
let mut edges = Vec::new();
let node_indices: Vec<_> = graph.node_indices().collect();
let idx_map: std::collections::HashMap<_, _> = node_indices
.iter()
.enumerate()
.map(|(i, &idx)| (idx, i as u64))
.collect();
for &idx in &node_indices {
for (neighbor_idx, edge) in graph.neighbors(idx) {
let from = idx_map[&idx];
let to = idx_map[&neighbor_idx];
let weight = 1.0 / (edge.error_probability + 1e-10);
if from < to {
edges.push((from, to, weight));
}
}
}
Ok(edges)
}
fn compute_local_cuts(
&self,
graph: &DetectorGraph,
edges: &[(u64, u64, Weight)],
) -> Result<Vec<f64>> {
let node_count = graph.node_count();
let mut local_cuts = vec![0.0; node_count];
for &(u, v, w) in edges {
local_cuts[u as usize] += w;
local_cuts[v as usize] += w;
}
Ok(local_cuts)
}
fn compute_conductance(&self, edges: &[(u64, u64, Weight)], node_count: usize) -> f64 {
if edges.is_empty() || node_count < 2 {
return 0.0;
}
let total_weight: f64 = edges.iter().map(|(_, _, w)| w).sum();
let avg_degree = 2.0 * total_weight / node_count as f64;
if avg_degree > 0.0 {
(1.0 / avg_degree).min(1.0)
} else {
0.0
}
}
fn compute_avg_weighted_degree(&self, edges: &[(u64, u64, Weight)], node_count: usize) -> f64 {
if node_count == 0 {
return 0.0;
}
let total_weight: f64 = edges.iter().map(|(_, _, w)| w).sum();
2.0 * total_weight / node_count as f64
}
fn compute_centrality(
&self,
graph: &DetectorGraph,
edges: &[(u64, u64, Weight)],
) -> Vec<f64> {
let node_count = graph.node_count();
if node_count == 0 {
return Vec::new();
}
let mut degrees = vec![0.0; node_count];
for &(u, v, w) in edges {
degrees[u as usize] += w;
degrees[v as usize] += w;
}
let max_degree = degrees.iter().cloned().fold(0.0, f64::max);
if max_degree > 0.0 {
degrees.iter().map(|&d| d / max_degree).collect()
} else {
degrees
}
}
fn estimate_spectral_gap(&self, conductance: f64) -> f64 {
conductance.powi(2) / 2.0
}
fn compute_clusters(&self, mincut_result: &MinCutResult, node_count: usize) -> Vec<usize> {
if let Some((ref s, ref t)) = mincut_result.partition {
let mut assignment = vec![0usize; node_count];
for &v in t {
if (v as usize) < node_count {
assignment[v as usize] = 1;
}
}
assignment
} else {
vec![0; node_count]
}
}
fn build_node_features(
&self,
graph: &DetectorGraph,
local_cuts: &[f64],
centrality: &[f64],
cluster_assignment: &[usize],
conductance: f64,
) -> Result<Vec<Vec<f32>>> {
let node_indices: Vec<_> = graph.node_indices().collect();
let mut features = Vec::with_capacity(node_indices.len());
for (i, &idx) in node_indices.iter().enumerate() {
let node = graph.node(idx).ok_or_else(|| {
NqedError::FeatureError("node not found".to_string())
})?;
let mut f = Vec::with_capacity(8);
f.push(node.x);
f.push(node.y);
f.push(if node.fired { 1.0 } else { 0.0 });
f.push(local_cuts.get(i).copied().unwrap_or(0.0) as f32);
f.push(centrality.get(i).copied().unwrap_or(0.0) as f32);
f.push(cluster_assignment.get(i).copied().unwrap_or(0) as f32);
f.push(conductance as f32);
f.push(graph.neighbors(idx).len() as f32);
features.push(f);
}
Ok(features)
}
fn normalize_features(&self, features: &mut StructuralFeatures) {
let max_local = features.local_cuts.iter().cloned().fold(0.0, f64::max);
if max_local > 0.0 {
features.local_cuts.iter_mut().for_each(|c| *c /= max_local);
}
for f in &mut features.node_features {
if f.len() > 3 && max_local > 0.0 {
f[3] /= max_local as f32;
}
}
}
#[inline]
#[must_use]
pub fn config(&self) -> &FeatureConfig {
&self.config
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FeatureSummary {
pub min_cut: f64,
pub conductance: f64,
pub avg_centrality: f64,
pub num_clusters: usize,
pub connected: bool,
}
impl StructuralFeatures {
#[must_use]
pub fn summary(&self) -> FeatureSummary {
let avg_centrality = if self.centrality.is_empty() {
0.0
} else {
self.centrality.iter().sum::<f64>() / self.centrality.len() as f64
};
let num_clusters = if self.cluster_assignment.is_empty() {
0
} else {
*self.cluster_assignment.iter().max().unwrap_or(&0) + 1
};
FeatureSummary {
min_cut: self.global_min_cut,
conductance: self.conductance,
avg_centrality,
num_clusters,
connected: !self.is_disconnected(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::DetectorGraphConfig;
#[test]
fn test_feature_config_default() {
let config = FeatureConfig::default();
assert!(config.compute_global_cut);
assert!(config.normalize);
}
#[test]
fn test_empty_graph() {
let config = FeatureConfig::default();
let extractor = StructuralFeatureExtractor::new(config);
let graph_config = DetectorGraphConfig::default();
let graph = DetectorGraph::new(graph_config);
let features = extractor.extract(&graph).unwrap();
assert_eq!(features.node_count(), 0);
}
#[test]
fn test_simple_graph() {
let config = FeatureConfig::default();
let extractor = StructuralFeatureExtractor::new(config);
let graph_config = DetectorGraphConfig::builder()
.code_distance(3)
.error_rate(0.01)
.build()
.unwrap();
let mut graph = DetectorGraph::new(graph_config);
graph.add_detector(0, 0.0, 0.0, 0, true);
graph.add_detector(1, 1.0, 0.0, 0, true);
graph.add_detector(2, 0.5, 1.0, 0, true);
graph.build_edges().unwrap();
let features = extractor.extract(&graph).unwrap();
assert_eq!(features.node_count(), 3);
assert!(!features.is_disconnected());
assert_eq!(features.node_features.len(), 3);
}
#[test]
fn test_feature_summary() {
let features = StructuralFeatures {
global_min_cut: 2.0,
conductance: 0.5,
centrality: vec![0.3, 0.5, 0.7],
cluster_assignment: vec![0, 0, 1],
..Default::default()
};
let summary = features.summary();
assert_eq!(summary.min_cut, 2.0);
assert_eq!(summary.num_clusters, 2);
assert!(summary.connected);
}
#[test]
fn test_disconnected_detection() {
let features = StructuralFeatures {
global_min_cut: 0.0,
..Default::default()
};
assert!(features.is_disconnected());
}
}