From 50d4062c08a3073325da251d94744c3cae431aca Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 7 May 2026 04:29:59 +0100 Subject: [PATCH] Add structured force organs and battleground --- .gitignore | 2 + README.md | 10 + RESEARCH.md | 2 + TUNING_DOCTRINE.md | 96 +++++++++ examples/organ_battleground.rs | 195 +++++++++++++++++ scripts/fetch_tuning_datasets.ps1 | 55 +++++ src/lib.rs | 342 +++++++++++++++++++++++++++++- 7 files changed, 694 insertions(+), 8 deletions(-) create mode 100644 examples/organ_battleground.rs create mode 100644 scripts/fetch_tuning_datasets.ps1 diff --git a/.gitignore b/.gitignore index ea8c4bf..05506e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /target +data/tuning/ +*_latest.csv diff --git a/README.md b/README.md index a7c3bbc..1673bf1 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,16 @@ For a live Bevy-style solver, call `solver_repulsion_accuracy_sweep(&solver, This is the tuning harness: sweep theta, grid radius, and later force-category scale knobs until the error/time curve stops paying rent. +Use `organ_battleground` for structure-aware force experiments: + +```bash +.\scripts\fetch_tuning_datasets.ps1 +cargo run --release --example organ_battleground +``` + +It compares raw node approximations and body-force candidates on synthetic +fixtures plus SNAP email/Gnutella datasets when available. + ## Graph Analysis And Folding The crate exposes `analyze(&graph)` for layout-independent structure reads: diff --git a/RESEARCH.md b/RESEARCH.md index 4899e31..6961513 100644 --- a/RESEARCH.md +++ b/RESEARCH.md @@ -89,6 +89,8 @@ Current state: policy for exact, Barnes-Hut, grid, and future multilevel force categories. - Local/global repulsion knobs now expose Barnes-Hut near radius, local pair scale, and aggregate far-field scale. +- `organ_battleground` evaluates raw and body-aware force candidates on + synthetic fixtures plus SNAP email/Gnutella datasets. Source: https://doi.org/10.1371/journal.pone.0098679 diff --git a/TUNING_DOCTRINE.md b/TUNING_DOCTRINE.md index 8ba02f8..238fc7c 100644 --- a/TUNING_DOCTRINE.md +++ b/TUNING_DOCTRINE.md @@ -211,6 +211,102 @@ The doctrine for now is simple: exact is the truth, Barnes-Hut is the large scale field, grid is local blunt force, and fold groups are the path to making large graphs readable without making every node audition for camera time. +## Organ Battleground + +Date: 2026-05-07 + +Harness: + +```bash +.\scripts\fetch_tuning_datasets.ps1 +cargo run --release --example organ_battleground +``` + +Actual datasets: + +- SNAP `email-Eu-core`: directed email network from a European research + institution, 1,005 nodes and 25,571 edges. +- SNAP `p2p-Gnutella08`: directed Gnutella peer-to-peer network snapshot from + August 8 2002, 6,301 nodes and 20,777 edges. The battleground currently + limits it to the first 3,000 remapped nodes for runtime. + +Synthetic fixtures: + +- `synthetic_layered_512`: hierarchy-heavy layered graph. +- `synthetic_clustered_1024`: clustered ring/fold graph. + +Candidate families: + +- raw exact node repulsion +- raw Barnes-Hut node repulsion +- raw spatial-grid node repulsion +- structured body candidates: + intra-body exact/grid plus inter-body coarse repulsion + +Representative results: + +| Dataset | Candidate | Time | Mean Rel Error | RMS Rel Error | Note | +|---|---:|---:|---:|---:|---| +| synthetic_layered_512 | exact | 1557 us | 0.000 | 0.000 | Baseline. | +| synthetic_layered_512 | Barnes-Hut theta 1.0 + body exact | 3774 us | 8.151 | 12.443 | Body forces are not raw-force approximations here. | +| synthetic_layered_512 | structured theta 0.65, body exact 0.20, body far 0.50 | 2852 us | 5.075 | 7.702 | Less bad, still semantic not physical. | +| synthetic_clustered_1024 | exact | 5651 us | 0.000 | 0.000 | Baseline. | +| synthetic_clustered_1024 | spatial grid | 2765 us | 0.491 | 0.498 | Best raw approximation among tested. | +| synthetic_clustered_1024 | structured grid body | 13920 us | 0.962 | 0.973 | Too much body overhead for this fixture. | +| snap_email_eu_core | exact | 3405 us | 0.000 | 0.000 | Baseline still cheap around 1k nodes. | +| snap_email_eu_core | spatial grid | 920 us | 0.489 | 0.567 | Fastest useful approximation. | +| snap_email_eu_core | Barnes-Hut theta 1.2 | 5978 us | 0.571 | 0.756 | Slower than exact here. | +| snap_p2p_gnutella08 sampled | exact | 29018 us | 0.000 | 0.000 | Baseline too expensive per frame. | +| snap_p2p_gnutella08 sampled | Barnes-Hut theta 1.0 | 17847 us | 0.669 | 0.828 | Speed win, high error. | +| snap_p2p_gnutella08 sampled | spatial grid | 2737 us | 0.784 | 0.941 | Huge speed win, poor far field. | + +### Hypothesis 8: body organs approximate exact node repulsion cheaply + +Result: rejected. + +The first body-force organs do not approximate raw exact node repulsion. They +add semantic pressure, so comparing them only against exact physical force makes +them look bad, because they are intentionally changing the field. + +Doctrine: + +- Do not treat body forces as a direct approximation of exact all-node + repulsion. +- Measure body forces with structural metrics: component separation, fold group + compactness, rank preservation, edge length variance, and camera-readable + body spacing. + +### Hypothesis 9: actual graph fixtures change the policy + +Result: confirmed. + +On real SNAP fixtures, exact remains viable around 1k nodes, spatial grid is a +very strong local approximation, and Barnes-Hut begins to matter on the larger +Gnutella sample. This matches the synthetic sweep direction, but the real graphs +make the crossover less theoretical and more irritatingly specific. + +Doctrine: + +- Keep exact for small real graphs. +- Use grid when local structure dominates and frame budget is tight. +- Use Barnes-Hut for larger broad graphs where far-field structure matters. +- Do not enable body organs by default as an "accuracy" optimization. They need + structure-quality metrics and likely multilevel coarse solving. + +### Next Organ Work + +The next battleground must score structure, not only force error: + +- fold group compactness before/after tick +- distance between community centers +- rank error versus Sugiyama target Y +- edge length variance +- bridge/articulation readability +- node displacement from exact trajectory after N ticks + +The organs are real, but they are not proven useful yet. Good. Better a failed +organ in a jar than one quietly installed in the patient. + ## Second Sweep: More Knobs Date: 2026-05-07 diff --git a/examples/organ_battleground.rs b/examples/organ_battleground.rs new file mode 100644 index 0000000..a360a0b --- /dev/null +++ b/examples/organ_battleground.rs @@ -0,0 +1,195 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use epiphany_graph_rs::{ + Graph, Layout3dConfig, Layout3dSolver, RepulsionAccuracyCandidate, RepulsionMode, + solver_structural_accuracy_sweep, +}; + +fn main() { + let candidates = [ + RepulsionAccuracyCandidate::exact(), + RepulsionAccuracyCandidate::barnes_hut(1.0), + RepulsionAccuracyCandidate::barnes_hut(1.2), + RepulsionAccuracyCandidate::spatial_grid(240.0, 1), + RepulsionAccuracyCandidate::structured(RepulsionMode::Exact, 0.20, 0.50, 256), + RepulsionAccuracyCandidate::structured(RepulsionMode::SpatialGrid, 0.25, 0.75, 512), + ]; + + println!( + "dataset,nodes,edges,communities,cores,mode,theta,body_mode,body_scale,body_far,elapsed_us,mean_abs,max_abs,mean_rel,rms_rel,max_rel" + ); + + for fixture in fixtures() { + let mut solver = Layout3dSolver::new(fixture.graph, Layout3dConfig::default()); + solver.tick(8); + let reports = solver_structural_accuracy_sweep(&solver, &candidates); + for report in reports { + println!( + "{},{},{},{},{},{},{:.2},{},{:.2},{:.2},{},{:.6},{:.6},{:.6},{:.6},{:.6}", + fixture.name, + solver.positions().len(), + fixture.edge_count, + solver.analysis().communities.len(), + solver.analysis().core_shells.len(), + mode_name(report.candidate.repulsion_mode), + report.candidate.barnes_hut_theta, + mode_name(report.candidate.body_repulsion_mode), + report.candidate.body_repulsion_scale, + report.candidate.body_far_repulsion_scale, + report.elapsed.as_micros(), + report.mean_absolute_error, + report.max_absolute_error, + report.mean_relative_error, + report.rms_relative_error, + report.max_relative_error + ); + } + } +} + +struct Fixture { + name: &'static str, + graph: Graph, + edge_count: usize, +} + +fn fixtures() -> Vec { + let mut fixtures = vec![ + Fixture { + name: "synthetic_layered_512", + graph: synthetic_layered(512), + edge_count: 0, + }, + Fixture { + name: "synthetic_clustered_1024", + graph: synthetic_clustered(1024), + edge_count: 0, + }, + ]; + + for (name, path, limit) in [ + ( + "snap_email_eu_core", + "data/tuning/email-Eu-core.txt", + 1500usize, + ), + ( + "snap_p2p_gnutella08", + "data/tuning/p2p-Gnutella08.txt", + 3000usize, + ), + ] { + if let Some((graph, edge_count)) = load_edge_list(path, limit) { + fixtures.push(Fixture { + name, + graph, + edge_count, + }); + } + } + + fixtures +} + +fn load_edge_list(path: &str, node_limit: usize) -> Option<(Graph, usize)> { + let path = Path::new(path); + let contents = fs::read_to_string(path).ok()?; + let mut ids = HashMap::::new(); + let mut edges = Vec::<(usize, usize)>::new(); + + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let mut parts = line.split_whitespace(); + let Some(source) = parts.next().and_then(|part| part.parse::().ok()) else { + continue; + }; + let Some(target) = parts.next().and_then(|part| part.parse::().ok()) else { + continue; + }; + + if !ids.contains_key(&source) { + if ids.len() >= node_limit { + continue; + } + ids.insert(source, ids.len()); + } + if !ids.contains_key(&target) { + if ids.len() >= node_limit { + continue; + } + ids.insert(target, ids.len()); + } + + edges.push((ids[&source], ids[&target])); + } + + let mut graph = Graph::with_capacity(ids.len(), edges.len()); + let nodes = (0..ids.len()) + .map(|_| graph.add_node(1.0)) + .collect::>(); + for (source, target) in &edges { + graph.add_edge(nodes[*source], nodes[*target]); + } + + Some((graph, edges.len())) +} + +fn synthetic_layered(count: usize) -> Graph { + let mut graph = Graph::with_capacity(count, count * 3); + let nodes = (0..count).map(|_| graph.add_node(1.0)).collect::>(); + let width = 32usize; + + for idx in 0..count { + let rank = idx / width; + let next_rank = rank + 1; + for offset in [0usize, 3, 11] { + let target = next_rank * width + ((idx + offset) % width); + if target < count { + graph.add_edge(nodes[idx], nodes[target]); + } + } + } + + graph +} + +fn synthetic_clustered(count: usize) -> Graph { + let mut graph = Graph::with_capacity(count, count * 4); + let nodes = (0..count).map(|_| graph.add_node(1.0)).collect::>(); + let clusters = 8usize; + let cluster_size = count / clusters; + + for cluster in 0..clusters { + let start = cluster * cluster_size; + let end = if cluster == clusters - 1 { + count + } else { + start + cluster_size + }; + for idx in start..end { + for step in [1usize, 2, 5] { + let target = start + ((idx - start + step) % (end - start)); + graph.add_edge(nodes[idx], nodes[target]); + } + } + if cluster + 1 < clusters { + graph.add_edge(nodes[start], nodes[(cluster + 1) * cluster_size]); + } + } + + graph +} + +fn mode_name(mode: RepulsionMode) -> &'static str { + match mode { + RepulsionMode::Exact => "exact", + RepulsionMode::BarnesHut => "barnes_hut", + RepulsionMode::SpatialGrid => "spatial_grid", + } +} diff --git a/scripts/fetch_tuning_datasets.ps1 b/scripts/fetch_tuning_datasets.ps1 new file mode 100644 index 0000000..5f3e0b9 --- /dev/null +++ b/scripts/fetch_tuning_datasets.ps1 @@ -0,0 +1,55 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSScriptRoot +$dataDir = Join-Path $root "data\tuning" +New-Item -ItemType Directory -Force -Path $dataDir | Out-Null + +$datasets = @( + @{ + Name = "email-Eu-core" + Url = "https://snap.stanford.edu/data/email-Eu-core.txt.gz" + Gz = "email-Eu-core.txt.gz" + Txt = "email-Eu-core.txt" + }, + @{ + Name = "p2p-Gnutella08" + Url = "https://snap.stanford.edu/data/p2p-Gnutella08.txt.gz" + Gz = "p2p-Gnutella08.txt.gz" + Txt = "p2p-Gnutella08.txt" + } +) + +foreach ($dataset in $datasets) { + $gzPath = Join-Path $dataDir $dataset.Gz + $txtPath = Join-Path $dataDir $dataset.Txt + + if (-not (Test-Path $gzPath) -and -not (Test-Path $txtPath)) { + Write-Host "Downloading $($dataset.Name) from $($dataset.Url)" + Invoke-WebRequest -Uri $dataset.Url -OutFile $gzPath + } + + if (-not (Test-Path $txtPath)) { + Write-Host "Expanding $($dataset.Gz)" + $inputStream = [System.IO.File]::OpenRead($gzPath) + try { + $gzipStream = [System.IO.Compression.GZipStream]::new( + $inputStream, + [System.IO.Compression.CompressionMode]::Decompress + ) + try { + $outputStream = [System.IO.File]::Create($txtPath) + try { + $gzipStream.CopyTo($outputStream) + } finally { + $outputStream.Dispose() + } + } finally { + $gzipStream.Dispose() + } + } finally { + $inputStream.Dispose() + } + } +} + +Write-Host "Datasets ready in $dataDir" diff --git a/src/lib.rs b/src/lib.rs index c1615a9..c48db9c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -164,6 +164,14 @@ pub struct Layout3dConfig { pub near_repulsion_scale: f32, /// Scale for Barnes-Hut aggregate far-field repulsion. pub far_repulsion_scale: f32, + /// Repulsion strategy used inside analyzed fold/body groups. + pub body_repulsion_mode: RepulsionMode, + /// Scale for intra-body node repulsion. + pub body_repulsion_scale: f32, + /// Maximum body size that receives exact intra-body repulsion. + pub body_exact_limit: usize, + /// Scale for repulsion between fold-group bodies. + pub body_far_repulsion_scale: f32, } impl Default for Layout3dConfig { @@ -191,6 +199,10 @@ impl Default for Layout3dConfig { barnes_hut_near_radius: 180.0, near_repulsion_scale: 1.0, far_repulsion_scale: 1.0, + body_repulsion_mode: RepulsionMode::Exact, + body_repulsion_scale: 0.35, + body_exact_limit: 512, + body_far_repulsion_scale: 0.8, } } } @@ -216,6 +228,10 @@ pub struct RepulsionAccuracyCandidate { pub barnes_hut_near_radius: f32, pub near_repulsion_scale: f32, pub far_repulsion_scale: f32, + pub body_repulsion_mode: RepulsionMode, + pub body_repulsion_scale: f32, + pub body_exact_limit: usize, + pub body_far_repulsion_scale: f32, } impl RepulsionAccuracyCandidate { @@ -228,6 +244,10 @@ impl RepulsionAccuracyCandidate { barnes_hut_near_radius: Layout3dConfig::default().barnes_hut_near_radius, near_repulsion_scale: Layout3dConfig::default().near_repulsion_scale, far_repulsion_scale: Layout3dConfig::default().far_repulsion_scale, + body_repulsion_mode: Layout3dConfig::default().body_repulsion_mode, + body_repulsion_scale: Layout3dConfig::default().body_repulsion_scale, + body_exact_limit: Layout3dConfig::default().body_exact_limit, + body_far_repulsion_scale: Layout3dConfig::default().body_far_repulsion_scale, } } @@ -240,6 +260,31 @@ impl RepulsionAccuracyCandidate { barnes_hut_near_radius: near_radius, near_repulsion_scale: Layout3dConfig::default().near_repulsion_scale, far_repulsion_scale: far_scale, + body_repulsion_mode: Layout3dConfig::default().body_repulsion_mode, + body_repulsion_scale: Layout3dConfig::default().body_repulsion_scale, + body_exact_limit: Layout3dConfig::default().body_exact_limit, + body_far_repulsion_scale: Layout3dConfig::default().body_far_repulsion_scale, + } + } + + pub fn structured( + body_mode: RepulsionMode, + body_scale: f32, + body_far_scale: f32, + exact_limit: usize, + ) -> Self { + Self { + repulsion_mode: RepulsionMode::BarnesHut, + barnes_hut_theta: Layout3dConfig::default().barnes_hut_theta, + grid_cell_size: Layout3dConfig::default().grid_cell_size, + grid_radius: Layout3dConfig::default().grid_radius, + barnes_hut_near_radius: Layout3dConfig::default().barnes_hut_near_radius, + near_repulsion_scale: Layout3dConfig::default().near_repulsion_scale, + far_repulsion_scale: Layout3dConfig::default().far_repulsion_scale, + body_repulsion_mode: body_mode, + body_repulsion_scale: body_scale, + body_exact_limit: exact_limit, + body_far_repulsion_scale: body_far_scale, } } @@ -252,6 +297,10 @@ impl RepulsionAccuracyCandidate { barnes_hut_near_radius: Layout3dConfig::default().barnes_hut_near_radius, near_repulsion_scale: Layout3dConfig::default().near_repulsion_scale, far_repulsion_scale: Layout3dConfig::default().far_repulsion_scale, + body_repulsion_mode: Layout3dConfig::default().body_repulsion_mode, + body_repulsion_scale: Layout3dConfig::default().body_repulsion_scale, + body_exact_limit: Layout3dConfig::default().body_exact_limit, + body_far_repulsion_scale: Layout3dConfig::default().body_far_repulsion_scale, } } @@ -264,6 +313,10 @@ impl RepulsionAccuracyCandidate { barnes_hut_near_radius: Layout3dConfig::default().barnes_hut_near_radius, near_repulsion_scale: Layout3dConfig::default().near_repulsion_scale, far_repulsion_scale: Layout3dConfig::default().far_repulsion_scale, + body_repulsion_mode: Layout3dConfig::default().body_repulsion_mode, + body_repulsion_scale: 0.0, + body_exact_limit: Layout3dConfig::default().body_exact_limit, + body_far_repulsion_scale: 0.0, } } } @@ -635,6 +688,42 @@ pub fn solver_repulsion_accuracy_sweep( repulsion_accuracy_sweep_tuples(solver.positions(), &solver.config, candidates) } +/// Compare full structural-force variants against an exact-node repulsion baseline. +/// +/// This is intentionally broader than `repulsion_accuracy_sweep`: it evaluates +/// node repulsion plus body-level structural repulsion, so it can measure the +/// cost and force delta of organ-style approximations. +pub fn solver_structural_accuracy_sweep( + solver: &Layout3dSolver, + candidates: &[RepulsionAccuracyCandidate], +) -> Vec { + let mut exact_config = solver.config.clone(); + exact_config.repulsion_mode = RepulsionMode::Exact; + exact_config.body_repulsion_scale = 0.0; + exact_config.body_far_repulsion_scale = 0.0; + let exact_forces = compute_repulsion_forces(solver.positions(), &exact_config); + + candidates + .iter() + .map(|candidate| { + let mut candidate_config = solver.config.clone(); + apply_candidate_config(candidate, &mut candidate_config); + + let started = Instant::now(); + let mut forces = compute_repulsion_forces(solver.positions(), &candidate_config); + apply_body_repulsion_3d( + solver.analysis(), + solver.positions(), + &mut forces, + &candidate_config, + ); + let elapsed = started.elapsed(); + + force_accuracy_report(*candidate, elapsed, &exact_forces, &forces) + }) + .collect() +} + fn repulsion_accuracy_sweep_tuples( positions: &[(f32, f32, f32)], config: &Layout3dConfig, @@ -648,13 +737,7 @@ fn repulsion_accuracy_sweep_tuples( .iter() .map(|candidate| { let mut candidate_config = config.clone(); - candidate_config.repulsion_mode = candidate.repulsion_mode; - candidate_config.barnes_hut_theta = candidate.barnes_hut_theta; - candidate_config.grid_cell_size = candidate.grid_cell_size; - candidate_config.grid_radius = candidate.grid_radius; - candidate_config.barnes_hut_near_radius = candidate.barnes_hut_near_radius; - candidate_config.near_repulsion_scale = candidate.near_repulsion_scale; - candidate_config.far_repulsion_scale = candidate.far_repulsion_scale; + apply_candidate_config(candidate, &mut candidate_config); let started = Instant::now(); let forces = compute_repulsion_forces(positions, &candidate_config); @@ -665,6 +748,20 @@ fn repulsion_accuracy_sweep_tuples( .collect() } +fn apply_candidate_config(candidate: &RepulsionAccuracyCandidate, config: &mut Layout3dConfig) { + config.repulsion_mode = candidate.repulsion_mode; + config.barnes_hut_theta = candidate.barnes_hut_theta; + config.grid_cell_size = candidate.grid_cell_size; + config.grid_radius = candidate.grid_radius; + config.barnes_hut_near_radius = candidate.barnes_hut_near_radius; + config.near_repulsion_scale = candidate.near_repulsion_scale; + config.far_repulsion_scale = candidate.far_repulsion_scale; + config.body_repulsion_mode = candidate.body_repulsion_mode; + config.body_repulsion_scale = candidate.body_repulsion_scale; + config.body_exact_limit = candidate.body_exact_limit; + config.body_far_repulsion_scale = candidate.body_far_repulsion_scale; +} + /// Analyze graph structure without running layout. pub fn analyze(graph: &Graph) -> GraphAnalysis { let n = graph.node_count(); @@ -1452,6 +1549,7 @@ fn apply_structural_forces_3d( apply_clustering_cohesion(graph, undirected, analysis, positions, forces, config); apply_cycle_folds(analysis, positions, forces, config); apply_bridge_hinges(graph, analysis, positions, forces, config); + apply_body_repulsion_3d(analysis, positions, forces, config); } fn apply_component_cohesion( @@ -1618,6 +1716,186 @@ fn apply_bridge_hinges( } } +fn apply_body_repulsion_3d( + analysis: &GraphAnalysis, + positions: &[(f32, f32, f32)], + forces: &mut [(f32, f32, f32)], + config: &Layout3dConfig, +) { + if config.body_repulsion_scale > 0.0 { + apply_intra_body_repulsion( + &analysis.strongly_connected_components, + positions, + forces, + config, + ); + apply_intra_body_repulsion(&analysis.communities, positions, forces, config); + apply_intra_body_repulsion(&analysis.biconnected_components, positions, forces, config); + } + + if config.body_far_repulsion_scale > 0.0 { + apply_inter_body_repulsion(&analysis.communities, positions, forces, config); + apply_inter_body_repulsion(&analysis.weak_components, positions, forces, config); + } +} + +fn apply_intra_body_repulsion( + components: &[Component], + positions: &[(f32, f32, f32)], + forces: &mut [(f32, f32, f32)], + config: &Layout3dConfig, +) { + for component in components { + if component.nodes.len() < 2 || component.nodes.len() > config.body_exact_limit { + continue; + } + + match config.body_repulsion_mode { + RepulsionMode::Exact | RepulsionMode::BarnesHut => { + for left in 0..component.nodes.len() { + for right in (left + 1)..component.nodes.len() { + apply_scaled_pair_repulsion_3d( + component.nodes[left].0, + component.nodes[right].0, + positions, + forces, + config, + config.body_repulsion_scale, + ); + } + } + } + RepulsionMode::SpatialGrid => { + apply_intra_body_grid_repulsion(component, positions, forces, config); + } + } + } +} + +fn apply_intra_body_grid_repulsion( + component: &Component, + positions: &[(f32, f32, f32)], + forces: &mut [(f32, f32, f32)], + config: &Layout3dConfig, +) { + let cell_size = config.grid_cell_size.max(1.0); + let radius = config.grid_radius.max(0); + let mut grid = HashMap::<(i32, i32, i32), Vec>::new(); + + for node in &component.nodes { + grid.entry(grid_cell(positions[node.0], cell_size)) + .or_default() + .push(node.0); + } + + for node in &component.nodes { + let a = node.0; + let (cx, cy, cz) = grid_cell(positions[a], cell_size); + for x in (cx - radius)..=(cx + radius) { + for y in (cy - radius)..=(cy + radius) { + for z in (cz - radius)..=(cz + radius) { + if let Some(candidates) = grid.get(&(x, y, z)) { + for &b in candidates { + if b > a { + apply_scaled_pair_repulsion_3d( + a, + b, + positions, + forces, + config, + config.body_repulsion_scale, + ); + } + } + } + } + } + } + } +} + +fn apply_inter_body_repulsion( + components: &[Component], + positions: &[(f32, f32, f32)], + forces: &mut [(f32, f32, f32)], + config: &Layout3dConfig, +) { + let bodies = components + .iter() + .filter(|component| component.nodes.len() > 1) + .map(|component| BodyParticle::from_component(component, positions)) + .collect::>(); + + for left in 0..bodies.len() { + for right in (left + 1)..bodies.len() { + apply_body_pair_repulsion(&bodies[left], &bodies[right], forces, config); + } + } +} + +#[derive(Clone, Debug)] +struct BodyParticle { + nodes: Vec, + center: [f32; 3], + radius: f32, + mass: f32, +} + +impl BodyParticle { + fn from_component(component: &Component, positions: &[(f32, f32, f32)]) -> Self { + let center = component_center(&component.nodes, positions); + let mut radius = 0.0f32; + for node in &component.nodes { + let dx = positions[node.0].0 - center[0]; + let dy = positions[node.0].1 - center[1]; + let dz = positions[node.0].2 - center[2]; + radius = radius.max((dx * dx + dy * dy + dz * dz).sqrt()); + } + + Self { + nodes: component.nodes.clone(), + center, + radius, + mass: component.nodes.len() as f32, + } + } +} + +fn apply_body_pair_repulsion( + left: &BodyParticle, + right: &BodyParticle, + forces: &mut [(f32, f32, f32)], + config: &Layout3dConfig, +) { + let dx = left.center[0] - right.center[0]; + let dy = left.center[1] - right.center[1]; + let dz = left.center[2] - right.center[2]; + let min_distance = (left.radius + right.radius).max(1.0); + let dist2 = (dx * dx + dy * dy + dz * dz).max(min_distance); + let dist = dist2.sqrt().max(0.01); + let magnitude = + config.base.repulsion_strength * config.body_far_repulsion_scale * left.mass * right.mass + / dist2; + let fx = dx / dist * magnitude * config.horizontal_repulsion; + let fy = dy / dist * magnitude * config.vertical_repulsion; + let fz = dz / dist * magnitude * config.horizontal_repulsion; + let left_share = 1.0 / left.mass.max(1.0); + let right_share = 1.0 / right.mass.max(1.0); + + for node in &left.nodes { + let idx = node.0; + forces[idx].0 += fx * left_share; + forces[idx].1 += fy * left_share; + forces[idx].2 += fz * left_share; + } + for node in &right.nodes { + let idx = node.0; + forces[idx].0 -= fx * right_share; + forces[idx].1 -= fy * right_share; + forces[idx].2 -= fz * right_share; + } +} + fn component_center(nodes: &[NodeId], positions: &[(f32, f32, f32)]) -> [f32; 3] { let mut center = [0.0f32; 3]; for node in nodes { @@ -2180,13 +2458,24 @@ fn apply_pair_repulsion_3d( positions: &[(f32, f32, f32)], forces: &mut [(f32, f32, f32)], config: &Layout3dConfig, +) { + apply_scaled_pair_repulsion_3d(a, b, positions, forces, config, config.near_repulsion_scale); +} + +fn apply_scaled_pair_repulsion_3d( + a: usize, + b: usize, + positions: &[(f32, f32, f32)], + forces: &mut [(f32, f32, f32)], + config: &Layout3dConfig, + scale: f32, ) { let dx = positions[a].0 - positions[b].0; let dy = positions[a].1 - positions[b].1; let dz = positions[a].2 - positions[b].2; let dist2 = (dx * dx + dy * dy + dz * dz).max(0.01); let dist = dist2.sqrt(); - let magnitude = config.base.repulsion_strength * config.near_repulsion_scale / dist2; + let magnitude = config.base.repulsion_strength * scale / dist2; let fx = dx / dist * magnitude * config.horizontal_repulsion; let fy = dy / dist * magnitude * config.vertical_repulsion; let fz = dz / dist * magnitude * config.horizontal_repulsion; @@ -2656,6 +2945,15 @@ mod tests { assert_eq!(candidate.far_repulsion_scale, 0.85); } + #[test] + fn exact_candidate_disables_body_forces() { + let candidate = RepulsionAccuracyCandidate::exact(); + + assert_eq!(candidate.repulsion_mode, RepulsionMode::Exact); + assert_eq!(candidate.body_repulsion_scale, 0.0); + assert_eq!(candidate.body_far_repulsion_scale, 0.0); + } + #[test] fn solver_repulsion_accuracy_sweep_uses_live_positions() { let mut graph = Graph::new(); @@ -2676,4 +2974,32 @@ mod tests { assert_eq!(reports.len(), 2); assert_eq!(reports[0].node_count, solver.positions().len()); } + + #[test] + fn solver_structural_accuracy_sweep_measures_body_candidates() { + let mut graph = Graph::new(); + let a = graph.add_node(1.0); + let b = graph.add_node(1.0); + let c = graph.add_node(1.0); + graph.add_edge(a, b); + graph.add_edge(b, c); + graph.add_edge(c, a); + let mut solver = Layout3dSolver::new(graph, Layout3dConfig::default()); + solver.tick(1); + + let reports = solver_structural_accuracy_sweep( + &solver, + &[ + RepulsionAccuracyCandidate::exact(), + RepulsionAccuracyCandidate::structured(RepulsionMode::Exact, 0.25, 0.5, 64), + ], + ); + + assert_eq!(reports.len(), 2); + assert!( + reports + .iter() + .all(|report| report.mean_relative_error.is_finite()) + ); + } }