Files
poimen-memory/crates/mem-cli/src/query/force_directed_layout.rs
T
rock d8c3b06cb0
CI / CI (push) Successful in 15m14s
fix: resolve 75 mem-cli compilation errors
All errors were API mismatches — handler code calling wrong method
   names, wrong argument types, or missing imports/derives. No logic
   changes. Build now passes with SQLX_OFFLINE=true.

   Key fixes:
   - embed_text -> embed_one, Vector -> Vec<f32> conversion
   - extract_token: extract auth header from HttpRequest first
   - AuthError variants aligned to actual enum definition
   - recursive async fns boxed (dfs_paths in inference + path_finder)
   - missing derives (Default, Serialize), imports (sqlx::Row, Timelike)
   - borrow-after-move: compute .len() before struct field move
   - streaming_body -> streaming with Result<Bytes> for SSE
   - CI: add SQLX_OFFLINE=true for offline builds without DB

   25 files changed, 99 insertions(+), 81 deletions(-)

Co-authored-by: rock <[email protected]>
2026-09-08 01:11:14 +00:00

267 lines
8.5 KiB
Rust

/// Force-directed layout algorithm for graph visualization.
///
/// Uses physics simulation (repulsive + attractive forces) to compute
/// node positions in 2D space suitable for React Flow visualization.
use serde::{Deserialize, Serialize};
use super::bfs_graph_traversal::{GraphData, TraversalNode, TraversalEdge};
/// 2D position (X, Y coordinates)
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct Position {
pub x: f32,
pub y: f32,
}
/// Force simulation parameters
#[derive(Debug, Clone)]
pub struct LayoutConfig {
pub iterations: usize, // Number of solver iterations (10-100)
pub charge: f32, // Repulsive force strength (-500 to -1000)
pub link_distance: f32, // Ideal edge length (50-150)
pub alpha_decay: f32, // Cooling rate (0.02-0.10)
pub width: f32, // Canvas width (default 800)
pub height: f32, // Canvas height (default 600)
}
impl Default for LayoutConfig {
fn default() -> Self {
Self {
iterations: 50,
charge: -800.0,
link_distance: 100.0,
alpha_decay: 0.05,
width: 800.0,
height: 600.0,
}
}
}
/// Layout result with computed positions
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayoutResult {
pub positions: std::collections::HashMap<String, Position>,
pub iterations_completed: usize,
pub layout_time_ms: u64,
}
/// Velocity for each node in simulation
#[derive(Debug, Clone, Copy)]
struct Velocity {
vx: f32,
vy: f32,
}
/// Force-directed layout engine
pub struct ForceDirectedLayout;
impl ForceDirectedLayout {
/// Compute layout for graph
pub fn layout(graph: &GraphData, config: &LayoutConfig) -> LayoutResult {
let start_time = std::time::Instant::now();
// Initialize positions randomly in canvas
let mut positions = Self::initialize_positions(&graph.nodes, config);
let mut velocities: std::collections::HashMap<String, Velocity> = graph.nodes
.iter()
.map(|n| (n.id.clone(), Velocity { vx: 0.0, vy: 0.0 }))
.collect();
// Simulation parameters
let mut alpha = 1.0;
let alpha_target = 0.001;
// Iterate until convergence
for iteration in 0..config.iterations {
// Apply forces
for node in &graph.nodes {
let mut fx = 0.0;
let mut fy = 0.0;
let pos = positions.get(&node.id).unwrap();
// 1. Repulsive forces (all pairs)
for other_node in &graph.nodes {
if node.id == other_node.id {
continue;
}
let other_pos = positions.get(&other_node.id).unwrap();
let (dfx, dfy) = Self::repulsive_force(
*pos,
*other_pos,
config.charge,
);
fx += dfx;
fy += dfy;
}
// 2. Attractive forces (linked nodes)
for edge in &graph.edges {
if edge.source_id == node.id {
let target_pos = positions.get(&edge.target_id).unwrap();
let (dfx, dfy) = Self::attractive_force(
*pos,
*target_pos,
config.link_distance,
);
fx += dfx;
fy += dfy;
}
}
// Update velocity (with damping)
let vel = velocities.get_mut(&node.id).unwrap();
vel.vx += fx * alpha;
vel.vy += fy * alpha;
vel.vx *= 0.95; // Damping
vel.vy *= 0.95;
}
// Update positions
for node in &graph.nodes {
let vel = velocities.get(&node.id).unwrap();
let pos = positions.get_mut(&node.id).unwrap();
pos.x += vel.vx;
pos.y += vel.vy;
// Boundary constraints
pos.x = pos.x.max(0.0).min(config.width);
pos.y = pos.y.max(0.0).min(config.height);
}
// Cool down (reduce step size)
alpha *= (alpha_target / alpha).powf(config.alpha_decay);
// Early exit if converged
if alpha < alpha_target {
return LayoutResult {
positions,
iterations_completed: iteration + 1,
layout_time_ms: start_time.elapsed().as_millis() as u64,
};
}
}
LayoutResult {
positions,
iterations_completed: config.iterations,
layout_time_ms: start_time.elapsed().as_millis() as u64,
}
}
/// Initialize random positions
fn initialize_positions(
nodes: &[TraversalNode],
config: &LayoutConfig,
) -> std::collections::HashMap<String, Position> {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut positions = std::collections::HashMap::new();
for node in nodes {
// Pseudo-random based on node ID (deterministic)
let mut hasher = DefaultHasher::new();
node.id.hash(&mut hasher);
let hash = hasher.finish();
let x = (hash as f32 % config.width).abs();
let y = ((hash >> 32) as f32 % config.height).abs();
positions.insert(node.id.clone(), Position { x, y });
}
positions
}
/// Coulomb repulsion force
fn repulsive_force(p1: Position, p2: Position, charge: f32) -> (f32, f32) {
let dx = p2.x - p1.x;
let dy = p2.y - p1.y;
let dist_sq = dx * dx + dy * dy + 1.0; // Add 1 to avoid singularity
let dist = dist_sq.sqrt();
let force = charge / dist_sq;
let fx = (force * dx / dist);
let fy = (force * dy / dist);
(-fx, -fy) // Negative = repulsive
}
/// Hooke's law attractive force
fn attractive_force(p1: Position, p2: Position, link_distance: f32) -> (f32, f32) {
let dx = p2.x - p1.x;
let dy = p2.y - p1.y;
let dist = (dx * dx + dy * dy).sqrt().max(0.1);
let displacement = dist - link_distance;
let force = 0.1 * displacement; // Spring constant
let fx = (force * dx / dist);
let fy = (force * dy / dist);
(fx, fy) // Positive = attractive
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_layout_config_defaults() {
let config = LayoutConfig::default();
assert_eq!(config.iterations, 50);
assert_eq!(config.width, 800.0);
assert_eq!(config.height, 600.0);
}
#[test]
fn test_position_creation() {
let pos = Position { x: 100.0, y: 200.0 };
assert_eq!(pos.x, 100.0);
assert_eq!(pos.y, 200.0);
}
#[test]
fn test_repulsive_force() {
let p1 = Position { x: 0.0, y: 0.0 };
let p2 = Position { x: 10.0, y: 0.0 };
let (fx, fy) = ForceDirectedLayout::repulsive_force(p1, p2, -800.0);
// Should push p1 away from p2 (negative x)
assert!(fx < 0.0);
assert_eq!(fy, 0.0); // No y component
}
#[test]
fn test_attractive_force() {
let p1 = Position { x: 0.0, y: 0.0 };
let p2 = Position { x: 100.0, y: 0.0 };
let (fx, fy) = ForceDirectedLayout::attractive_force(p1, p2, 50.0);
// Distance is 100, ideal is 50, so pull p1 towards p2 (positive x)
assert!(fx > 0.0);
assert_eq!(fy, 0.0);
}
#[test]
fn test_layout_result_creation() {
let mut positions = std::collections::HashMap::new();
positions.insert("n1".to_string(), Position { x: 10.0, y: 20.0 });
let result = LayoutResult {
positions,
iterations_completed: 25,
layout_time_ms: 150,
};
assert_eq!(result.iterations_completed, 25);
assert_eq!(result.layout_time_ms, 150);
}
}