Add structured force organs and battleground
This commit is contained in:
parent
b5af7f0f04
commit
50d4062c08
7 changed files with 694 additions and 8 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1 +1,3 @@
|
|||
/target
|
||||
data/tuning/
|
||||
*_latest.csv
|
||||
|
|
|
|||
10
README.md
10
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
195
examples/organ_battleground.rs
Normal file
195
examples/organ_battleground.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
55
scripts/fetch_tuning_datasets.ps1
Normal file
55
scripts/fetch_tuning_datasets.ps1
Normal 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"
|
||||
342
src/lib.rs
342
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<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(
|
||||
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<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] {
|
||||
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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Reference in a new issue