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
|
/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
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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,
|
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())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Reference in a new issue