use deep_causality_algorithms::brcd::brcd_augment::{augmented_graph, get_configurations_multi};
use deep_causality_algorithms::brcd::brcd_boss_cpdag::dag_to_cpdag;
use deep_causality_algorithms::brcd::brcd_config::{BrcdConfig, ConfigStrategy};
use deep_causality_algorithms::brcd::brcd_mapconfig::find_map_configs;
use deep_causality_algorithms::brcd::brcd_run;
use deep_causality_algorithms::dag_sampling::mec_size as poly_mec_size;
use deep_causality_rand::{Distribution, Normal, Rng, Xoshiro256};
use deep_causality_tensor::CausalTensor;
use deep_causality_topology::MixedGraph;
use std::time::Instant;
const MAX_CONFIG_EDGES: usize = 16;
struct Case {
normal: CausalTensor<f64>,
anomalous: CausalTensor<f64>,
cpdag: MixedGraph<()>,
rc: usize,
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let run = |name: &str| args.is_empty() || args.iter().any(|a| a == name);
println!();
println!("BRCD evaluation harness — MapPrune vs Full (accuracy / compute)");
println!();
if run("a") {
sweep_a();
}
if run("b") {
sweep_b();
}
if run("c") {
sweep_c();
}
}
fn sweep_a() {
println!("============================================================================");
println!("SWEEP A — controlled degree (planted cliques, du = c-1, perturb = 4.0)");
println!("Config-eval counts are EXACT/DETERMINISTIC: Full = 2^du, MapPrune = du+1.");
println!("Wall-clock is the median of >=5 seeds (machine-dependent, indicative).");
println!("============================================================================");
println!(
"{:>3} {:>3} | {:>10} {:>10} | {:>9} {:>9} | {:>9} {:>9} | {:>10} {:>10}",
"c",
"du",
"Full cfg",
"MAP eval",
"Full t1",
"MAP t1",
"Full t3",
"MAP t3",
"Full ms",
"MAP ms"
);
println!("{}", "-".repeat(108));
let perturb = 4.0;
let n_each = 150usize;
let seeds = 5usize;
let clique_sizes: Vec<usize> = (4..=13).chain([18usize, 22, 26]).collect();
for &c in &clique_sizes {
let du = c - 1;
let full_feasible = du <= MAX_CONFIG_EDGES;
let probe = make_clique_case(c, 8, perturb, 0xC0FFEE + c as u64);
let full_cfg = full_valid_configs(&probe.cpdag, probe.rc);
let map_eval = mapprune_evals(&probe.cpdag, probe.rc);
let mut full_t1 = 0usize;
let mut full_t3 = 0usize;
let mut map_t1 = 0usize;
let mut map_t3 = 0usize;
let mut full_ms: Vec<f64> = Vec::new();
let mut map_ms: Vec<f64> = Vec::new();
for s in 0..seeds {
let seed = 0x000C_116E + (c as u64) * 7919 + s as u64;
let case = make_clique_case(c, n_each, perturb, seed);
let t = Instant::now();
let prune = brcd_run(
&case.normal,
&case.anomalous,
Some(&case.cpdag),
&cfg(ConfigStrategy::MapPrune, seed),
)
.expect("MapPrune run");
map_ms.push(t.elapsed().as_secs_f64() * 1e3);
if top1_hits(prune.top(), case.rc) {
map_t1 += 1;
}
if top3_hits(prune.ranks(), case.rc) {
map_t3 += 1;
}
if full_feasible {
let t = Instant::now();
let full = brcd_run(
&case.normal,
&case.anomalous,
Some(&case.cpdag),
&cfg(ConfigStrategy::Full, seed),
)
.expect("Full run");
full_ms.push(t.elapsed().as_secs_f64() * 1e3);
if top1_hits(full.top(), case.rc) {
full_t1 += 1;
}
if top3_hits(full.ranks(), case.rc) {
full_t3 += 1;
}
}
}
let pct = |hits: usize| -> String { format!("{:.0}%", 100.0 * hits as f64 / seeds as f64) };
let full_cfg_s = full_cfg.map_or_else(|| "—".to_string(), |v| v.to_string());
let (full_t1_s, full_t3_s, full_ms_s) = if full_feasible {
(
pct(full_t1),
pct(full_t3),
format!("{:.2}", median_ms(&mut full_ms)),
)
} else {
("—".to_string(), "—".to_string(), "—".to_string())
};
println!(
"{:>3} {:>3} | {:>10} {:>10} | {:>9} {:>9} | {:>9} {:>9} | {:>10} {:>10}",
c,
du,
full_cfg_s,
map_eval,
full_t1_s,
pct(map_t1),
full_t3_s,
pct(map_t3),
full_ms_s,
format!("{:.2}", median_ms(&mut map_ms)),
);
}
println!("{}", "-".repeat(108));
println!(
"Reading: top-1 is identical (both 100%) where Full is feasible; Full's exact config\n\
count is 2^du and its time explodes, while MapPrune stays at du+1 evals and flat time.\n\
Past du = {MAX_CONFIG_EDGES} (c >= 18) Full refuses (\"—\") and only MapPrune completes."
);
println!();
}
fn sweep_b() {
println!("============================================================================");
println!("SWEEP B — scaled n (random linear-Gaussian CPDAGs, perturb = 3.0)");
println!(">=10 graphs per n; accuracy ~ equal. On these low-du graphs Full's tiny");
println!("enumeration is already cheap, so MapPrune's finder overhead makes it slower.");
println!("============================================================================");
println!(
"{:>4} | {:>9} {:>9} | {:>9} {:>9} | {:>10} {:>10} | {:>9}",
"n", "Full t1", "MAP t1", "Full t3", "MAP t3", "Full ms", "MAP ms", "agree-t1"
);
println!("{}", "-".repeat(86));
let perturb = 3.0;
let n_each = 150usize;
let n_graphs = 12usize; let p_edge = 0.30;
for &n in &[10usize, 25, 50, 75, 100] {
let mut trials = 0usize;
let mut full_t1 = 0usize;
let mut full_t3 = 0usize;
let mut map_t1 = 0usize;
let mut map_t3 = 0usize;
let mut agree_t1 = 0usize;
let mut full_ms: Vec<f64> = Vec::new();
let mut map_ms: Vec<f64> = Vec::new();
let mut gi = 0u64;
while trials < n_graphs {
let seed = 0x5CA1ED + (n as u64) * 1_000_003 + gi;
gi += 1;
if gi > n_graphs as u64 * 40 {
break; }
let Some(case) = make_case(n, p_edge, n_each, perturb, seed) else {
continue;
};
let has_undirected = (0..n).any(|v| !case.cpdag.undirected_neighbors(v).is_empty());
if !has_undirected {
continue;
}
if full_valid_configs(&case.cpdag, case.rc).is_none() {
continue;
}
let t = Instant::now();
let full = brcd_run(
&case.normal,
&case.anomalous,
Some(&case.cpdag),
&cfg(ConfigStrategy::Full, seed),
)
.expect("Full run");
full_ms.push(t.elapsed().as_secs_f64() * 1e3);
let t = Instant::now();
let prune = brcd_run(
&case.normal,
&case.anomalous,
Some(&case.cpdag),
&cfg(ConfigStrategy::MapPrune, seed),
)
.expect("MapPrune run");
map_ms.push(t.elapsed().as_secs_f64() * 1e3);
trials += 1;
if top1_hits(full.top(), case.rc) {
full_t1 += 1;
}
if top3_hits(full.ranks(), case.rc) {
full_t3 += 1;
}
if top1_hits(prune.top(), case.rc) {
map_t1 += 1;
}
if top3_hits(prune.ranks(), case.rc) {
map_t3 += 1;
}
if full.top() == prune.top() {
agree_t1 += 1;
}
}
let pct = |hits: usize| -> String {
if trials == 0 {
"—".to_string()
} else {
format!("{:.0}%", 100.0 * hits as f64 / trials as f64)
}
};
println!(
"{:>4} | {:>9} {:>9} | {:>9} {:>9} | {:>10} {:>10} | {:>9}",
format!("{n}({trials})"),
pct(full_t1),
pct(map_t1),
pct(full_t3),
pct(map_t3),
format!("{:.2}", median_ms(&mut full_ms)),
format!("{:.2}", median_ms(&mut map_ms)),
pct(agree_t1),
);
}
println!("{}", "-".repeat(86));
println!(
"Reading: Full and MapPrune top-1/top-3 accuracy track each other and agree on nearly\n\
every graph (agree-t1). Here MapPrune is SLOWER in wall-clock: on low-du random CPDAGs\n\
Full enumerates only a handful of configs, so the finder's hill-climb bookkeeping costs\n\
more than direct enumeration. The compute win is the high-local-degree regime (Sweep A),\n\
where 2^du is the wall; on low-du graphs full enumeration is already cheap."
);
println!("n label shows n(trials).");
println!();
}
fn sweep_c() {
println!("============================================================================");
println!("SWEEP C — large n (bounded-degree random CPDAGs, avg in-degree ~ 2)");
println!("Fig-2b axis: runtime vs n out to 1000. p_edge = 2/n keeps degree bounded so");
println!("the curve reflects n-scaling, not a dense blow-up. Same graphs for both.");
println!("============================================================================");
println!(
"{:>7} | {:>9} {:>9} | {:>9} {:>9} | {:>11} {:>11} | {:>9}",
"n", "Full t1", "MAP t1", "Full t3", "MAP t3", "Full ms", "MAP ms", "agree-t1"
);
println!("{}", "-".repeat(92));
let perturb = 3.0;
let n_rows = 150usize;
let avg_deg = 2.0;
for &(n, n_graphs) in &[(50usize, 4usize), (100, 4), (250, 3), (500, 2), (1000, 2)] {
let p_edge = (avg_deg / n as f64).min(0.5);
let mut trials = 0usize;
let mut full_t1 = 0usize;
let mut full_t3 = 0usize;
let mut map_t1 = 0usize;
let mut map_t3 = 0usize;
let mut agree_t1 = 0usize;
let mut full_ms: Vec<f64> = Vec::new();
let mut map_ms: Vec<f64> = Vec::new();
let mut gi = 0u64;
while trials < n_graphs {
let seed = 0xF16B + (n as u64) * 2_750_159 + gi;
gi += 1;
if gi > n_graphs as u64 * 60 {
break; }
let Some(case) = make_case(n, p_edge, n_rows, perturb, seed) else {
continue;
};
let has_undirected = (0..n).any(|v| !case.cpdag.undirected_neighbors(v).is_empty());
if !has_undirected {
continue;
}
if full_valid_configs(&case.cpdag, case.rc).is_none() {
continue; }
let t = Instant::now();
let full = brcd_run(
&case.normal,
&case.anomalous,
Some(&case.cpdag),
&cfg(ConfigStrategy::Full, seed),
)
.expect("Full run");
full_ms.push(t.elapsed().as_secs_f64() * 1e3);
let t = Instant::now();
let prune = brcd_run(
&case.normal,
&case.anomalous,
Some(&case.cpdag),
&cfg(ConfigStrategy::MapPrune, seed),
)
.expect("MapPrune run");
map_ms.push(t.elapsed().as_secs_f64() * 1e3);
trials += 1;
if top1_hits(full.top(), case.rc) {
full_t1 += 1;
}
if top3_hits(full.ranks(), case.rc) {
full_t3 += 1;
}
if top1_hits(prune.top(), case.rc) {
map_t1 += 1;
}
if top3_hits(prune.ranks(), case.rc) {
map_t3 += 1;
}
if full.top() == prune.top() {
agree_t1 += 1;
}
eprintln!(" [sweep C] n={n}: graph {trials}/{n_graphs} done");
}
let pct = |hits: usize| -> String {
if trials == 0 {
"—".to_string()
} else {
format!("{:.0}%", 100.0 * hits as f64 / trials as f64)
}
};
println!(
"{:>7} | {:>9} {:>9} | {:>9} {:>9} | {:>11} {:>11} | {:>9}",
format!("{n}({trials})"),
pct(full_t1),
pct(map_t1),
pct(full_t3),
pct(map_t3),
format!("{:.1}", median_ms(&mut full_ms)),
format!("{:.1}", median_ms(&mut map_ms)),
pct(agree_t1),
);
}
println!("{}", "-".repeat(92));
println!(
"Reading: both strategies COMPLETE at n = 1000 in pure Rust (Full ~11 s, MapPrune ~29 s)\n\
with NO exponential in n — the bounded-degree regime removes the 2^du wall entirely.\n\
But wall-clock here is super-linear, empirically ~n^2.5-3 (roughly cubic; dominated by\n\
per-candidate graph augmentation/scoring over all n nodes), NOT near-linear. MapPrune is\n\
~2.6x slower than Full at bounded du (finder overhead, as in Sweep B). Accuracy is\n\
identical (top-1/top-3 100%, 100% agree). This reproduces the Fig-2b *axis* on a\n\
Rust-native continuous generator — NOT the paper's discrete pyAgrum protocol — so read it\n\
as scaling shape + practical latency (well under the original's reported ~150 s at\n\
n = 1000), not a controlled head-to-head."
);
println!("n label shows n(trials).");
println!();
}
fn make_case(n: usize, p_edge: f64, n_each: usize, perturb: f64, seed: u64) -> Option<Case> {
let mut rng = Xoshiro256::from_seed(seed);
let eps = Normal::new(0.0_f64, 1.0).unwrap();
let mut parents: Vec<Vec<usize>> = vec![Vec::new(); n];
let mut weight: Vec<Vec<f64>> = vec![Vec::new(); n];
for i in 0..n {
for j in 0..i {
if rng.random_range(0.0..1.0) < p_edge {
parents[i].push(j);
let sign = if rng.random_range(0.0..1.0) < 0.5 {
-1.0
} else {
1.0
};
weight[i].push(sign * (0.5 + rng.random_range(0.0..1.0)));
}
}
}
let cpdag = dag_to_cpdag(&parents).ok()?;
let rc = (0..n)
.find(|&v| !cpdag.undirected_neighbors(v).is_empty())
.unwrap_or(0);
let mut normal = Vec::with_capacity(n_each * n);
let mut anomalous = Vec::with_capacity(n_each * n);
for regime in 0..2 {
for _ in 0..n_each {
let mut x = vec![0.0_f64; n];
for i in 0..n {
let mut mean = if regime == 1 && i == rc { perturb } else { 0.0 };
for (k, &j) in parents[i].iter().enumerate() {
mean += weight[i][k] * x[j];
}
x[i] = mean + eps.sample(&mut rng);
}
let dst = if regime == 0 {
&mut normal
} else {
&mut anomalous
};
dst.extend_from_slice(&x);
}
}
Some(Case {
normal: CausalTensor::new(normal, vec![n_each, n]).unwrap(),
anomalous: CausalTensor::new(anomalous, vec![n_each, n]).unwrap(),
cpdag,
rc,
})
}
fn make_clique_case(c: usize, n_each: usize, perturb: f64, seed: u64) -> Case {
let mut rng = Xoshiro256::from_seed(seed);
let eps = Normal::new(0.0_f64, 1.0).unwrap();
let mut parents: Vec<Vec<usize>> = vec![Vec::new(); c];
let mut weight: Vec<Vec<f64>> = vec![Vec::new(); c];
for i in 0..c {
for j in 0..i {
parents[i].push(j);
let s = if rng.random_range(0.0..1.0) < 0.5 {
-1.0
} else {
1.0
};
weight[i].push(s * (0.5 + rng.random_range(0.0..1.0)));
}
}
let cpdag = dag_to_cpdag(&parents).expect("clique cpdag");
let mut normal = Vec::with_capacity(n_each * c);
let mut anomalous = Vec::with_capacity(n_each * c);
for regime in 0..2 {
for _ in 0..n_each {
let mut x = vec![0.0_f64; c];
for i in 0..c {
let mut mean = if regime == 1 && i == 0 { perturb } else { 0.0 };
for (k, &j) in parents[i].iter().enumerate() {
mean += weight[i][k] * x[j];
}
x[i] = mean + eps.sample(&mut rng);
}
let dst = if regime == 0 {
&mut normal
} else {
&mut anomalous
};
dst.extend_from_slice(&x);
}
}
Case {
normal: CausalTensor::new(normal, vec![n_each, c]).unwrap(),
anomalous: CausalTensor::new(anomalous, vec![n_each, c]).unwrap(),
cpdag,
rc: 0,
}
}
fn cfg(strategy: ConfigStrategy, seed: u64) -> BrcdConfig<f64> {
let mut c = BrcdConfig::<f64>::continuous(seed);
c.config_strategy = strategy;
c
}
fn median_ms(samples: &mut [f64]) -> f64 {
samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
let m = samples.len() / 2;
if samples.len() % 2 == 1 {
samples[m]
} else {
0.5 * (samples[m - 1] + samples[m])
}
}
fn full_valid_configs(cpdag: &MixedGraph<()>, rc: usize) -> Option<usize> {
get_configurations_multi(cpdag, &[rc]).ok().map(|v| v.len())
}
fn mapprune_evals(cpdag: &MixedGraph<()>, rc: usize) -> usize {
find_map_configs::<f64, (), _>(cpdag, &[rc], |g| {
let aug = augmented_graph(g, &[rc])?;
poly_mec_size::<f64, ()>(&aug)
})
.expect("finder")
.evals
}
fn top1_hits(top: Option<&[usize]>, rc: usize) -> bool {
top == Some([rc].as_slice())
}
fn top3_hits(ranks: &[Vec<usize>], rc: usize) -> bool {
ranks.iter().take(3).any(|c| c.as_slice() == [rc])
}