Add structured force organs and battleground

This commit is contained in:
Meta Vens 2026-05-07 04:29:59 +01:00
commit 50d4062c08
7 changed files with 694 additions and 8 deletions

2
.gitignore vendored
View file

@ -1 +1,3 @@
/target /target
data/tuning/
*_latest.csv

View file

@ -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 This is the tuning harness: sweep theta, grid radius, and later force-category
scale knobs until the error/time curve stops paying rent. 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 ## Graph Analysis And Folding
The crate exposes `analyze(&graph)` for layout-independent structure reads: The crate exposes `analyze(&graph)` for layout-independent structure reads:

View file

@ -89,6 +89,8 @@ Current state:
policy for exact, Barnes-Hut, grid, and future multilevel force categories. policy for exact, Barnes-Hut, grid, and future multilevel force categories.
- Local/global repulsion knobs now expose Barnes-Hut near radius, local pair - Local/global repulsion knobs now expose Barnes-Hut near radius, local pair
scale, and aggregate far-field scale. scale, and aggregate far-field scale.
- `organ_battleground` evaluates raw and body-aware force candidates on
synthetic fixtures plus SNAP email/Gnutella datasets.
Source: Source:
https://doi.org/10.1371/journal.pone.0098679 https://doi.org/10.1371/journal.pone.0098679

View file

@ -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 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. 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 ## Second Sweep: More Knobs
Date: 2026-05-07 Date: 2026-05-07

View file

@ -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<Fixture> {
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::<usize, usize>::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::<usize>().ok()) else {
continue;
};
let Some(target) = parts.next().and_then(|part| part.parse::<usize>().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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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",
}
}

View file

@ -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"

View file

@ -164,6 +164,14 @@ pub struct Layout3dConfig {
pub near_repulsion_scale: f32, pub near_repulsion_scale: f32,
/// Scale for Barnes-Hut aggregate far-field repulsion. /// Scale for Barnes-Hut aggregate far-field repulsion.
pub far_repulsion_scale: f32, 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 { impl Default for Layout3dConfig {
@ -191,6 +199,10 @@ impl Default for Layout3dConfig {
barnes_hut_near_radius: 180.0, barnes_hut_near_radius: 180.0,
near_repulsion_scale: 1.0, near_repulsion_scale: 1.0,
far_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 barnes_hut_near_radius: f32,
pub near_repulsion_scale: f32, pub near_repulsion_scale: f32,
pub far_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 { impl RepulsionAccuracyCandidate {
@ -228,6 +244,10 @@ impl RepulsionAccuracyCandidate {
barnes_hut_near_radius: Layout3dConfig::default().barnes_hut_near_radius, barnes_hut_near_radius: Layout3dConfig::default().barnes_hut_near_radius,
near_repulsion_scale: Layout3dConfig::default().near_repulsion_scale, near_repulsion_scale: Layout3dConfig::default().near_repulsion_scale,
far_repulsion_scale: Layout3dConfig::default().far_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, barnes_hut_near_radius: near_radius,
near_repulsion_scale: Layout3dConfig::default().near_repulsion_scale, near_repulsion_scale: Layout3dConfig::default().near_repulsion_scale,
far_repulsion_scale: far_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, barnes_hut_near_radius: Layout3dConfig::default().barnes_hut_near_radius,
near_repulsion_scale: Layout3dConfig::default().near_repulsion_scale, near_repulsion_scale: Layout3dConfig::default().near_repulsion_scale,
far_repulsion_scale: Layout3dConfig::default().far_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, barnes_hut_near_radius: Layout3dConfig::default().barnes_hut_near_radius,
near_repulsion_scale: Layout3dConfig::default().near_repulsion_scale, near_repulsion_scale: Layout3dConfig::default().near_repulsion_scale,
far_repulsion_scale: Layout3dConfig::default().far_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) 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<ForceAccuracyReport> {
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( fn repulsion_accuracy_sweep_tuples(
positions: &[(f32, f32, f32)], positions: &[(f32, f32, f32)],
config: &Layout3dConfig, config: &Layout3dConfig,
@ -648,13 +737,7 @@ fn repulsion_accuracy_sweep_tuples(
.iter() .iter()
.map(|candidate| { .map(|candidate| {
let mut candidate_config = config.clone(); let mut candidate_config = config.clone();
candidate_config.repulsion_mode = candidate.repulsion_mode; apply_candidate_config(candidate, &mut candidate_config);
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;
let started = Instant::now(); let started = Instant::now();
let forces = compute_repulsion_forces(positions, &candidate_config); let forces = compute_repulsion_forces(positions, &candidate_config);
@ -665,6 +748,20 @@ fn repulsion_accuracy_sweep_tuples(
.collect() .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. /// Analyze graph structure without running layout.
pub fn analyze(graph: &Graph) -> GraphAnalysis { pub fn analyze(graph: &Graph) -> GraphAnalysis {
let n = graph.node_count(); let n = graph.node_count();
@ -1452,6 +1549,7 @@ fn apply_structural_forces_3d(
apply_clustering_cohesion(graph, undirected, analysis, positions, forces, config); apply_clustering_cohesion(graph, undirected, analysis, positions, forces, config);
apply_cycle_folds(analysis, positions, forces, config); apply_cycle_folds(analysis, positions, forces, config);
apply_bridge_hinges(graph, analysis, positions, forces, config); apply_bridge_hinges(graph, analysis, positions, forces, config);
apply_body_repulsion_3d(analysis, positions, forces, config);
} }
fn apply_component_cohesion( 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<usize>>::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::<Vec<_>>();
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<NodeId>,
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] { fn component_center(nodes: &[NodeId], positions: &[(f32, f32, f32)]) -> [f32; 3] {
let mut center = [0.0f32; 3]; let mut center = [0.0f32; 3];
for node in nodes { for node in nodes {
@ -2180,13 +2458,24 @@ fn apply_pair_repulsion_3d(
positions: &[(f32, f32, f32)], positions: &[(f32, f32, f32)],
forces: &mut [(f32, f32, f32)], forces: &mut [(f32, f32, f32)],
config: &Layout3dConfig, 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 dx = positions[a].0 - positions[b].0;
let dy = positions[a].1 - positions[b].1; let dy = positions[a].1 - positions[b].1;
let dz = positions[a].2 - positions[b].2; let dz = positions[a].2 - positions[b].2;
let dist2 = (dx * dx + dy * dy + dz * dz).max(0.01); let dist2 = (dx * dx + dy * dy + dz * dz).max(0.01);
let dist = dist2.sqrt(); 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 fx = dx / dist * magnitude * config.horizontal_repulsion;
let fy = dy / dist * magnitude * config.vertical_repulsion; let fy = dy / dist * magnitude * config.vertical_repulsion;
let fz = dz / dist * magnitude * config.horizontal_repulsion; let fz = dz / dist * magnitude * config.horizontal_repulsion;
@ -2656,6 +2945,15 @@ mod tests {
assert_eq!(candidate.far_repulsion_scale, 0.85); 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] #[test]
fn solver_repulsion_accuracy_sweep_uses_live_positions() { fn solver_repulsion_accuracy_sweep_uses_live_positions() {
let mut graph = Graph::new(); let mut graph = Graph::new();
@ -2676,4 +2974,32 @@ mod tests {
assert_eq!(reports.len(), 2); assert_eq!(reports.len(), 2);
assert_eq!(reports[0].node_count, solver.positions().len()); 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())
);
}
} }