Skip to content

Commit 2ef18ed

Browse files
authored
Merge pull request #220 from AdaWorldAPI/claude/chaoda-ensemble-v1
feat(clam): CHAODA multi-method ensemble — clears the synthetic PROBE-CHAODA-1000G bar (AUC 0.62 -> 0.99)
2 parents 497b04a + a630d77 commit 2ef18ed

1 file changed

Lines changed: 266 additions & 0 deletions

File tree

src/hpc/clam.rs

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1576,6 +1576,192 @@ impl ClamTree {
15761576
.filter(|a| a.score >= threshold)
15771577
.collect()
15781578
}
1579+
1580+
/// Default leaf-count cap for the quadratic connected-component term of
1581+
/// [`ensemble_anomaly_scores`](Self::ensemble_anomaly_scores). Above this many
1582+
/// leaves the public API falls back to the linear path-minority signal alone.
1583+
pub const ENSEMBLE_GRAPH_BUDGET: usize = 4096;
1584+
1585+
/// Multi-method CHAODA anomaly ensemble — increment 1 of `D-GEN-CHAODA-ENSEMBLE`
1586+
/// (lance-graph `genetics-probes-v1.md`).
1587+
///
1588+
/// The single-method [`anomaly_scores`](Self::anomaly_scores) signal scores
1589+
/// each point by its leaf cluster's local fractal dimension (LFD). LFD
1590+
/// measures *intra-leaf* geometry complexity, not *inter-leaf* isolation, so
1591+
/// it does not separate isolated outliers from dense clusters (measured
1592+
/// ROC-AUC ≈ 0.62 on a synthetic mixture; see the spike test). This method
1593+
/// adds isolation-sensitive CHAODA signals (Ishaq et al. 2021):
1594+
///
1595+
/// - **parent-child path-minority ratio** (dominant; always computed;
1596+
/// `O(L · depth)`): walking a leaf up to the root, the minimum
1597+
/// `child_cardinality / parent_cardinality` ratio is tiny for a point that
1598+
/// split off as a minority (an isolated outlier) and moderate for one that
1599+
/// always stayed in the majority (a dense-cluster member). Immune to the
1600+
/// leaf-fragmentation that defeats raw leaf cardinality / degree.
1601+
/// - **connected-component cardinality** over the leaf-overlap graph (an edge
1602+
/// joins two leaves whose volumes overlap, `dist(cᵢ, cⱼ) ≤ rᵢ + rⱼ`; small
1603+
/// components are anomalous): a refinement averaged in **only when the leaf
1604+
/// count is within `graph_budget`**, because the overlap build is
1605+
/// `O(L² · vec_len)`.
1606+
///
1607+
/// Every point inherits its leaf's score. Raw leaf cardinality and vertex
1608+
/// degree are not used (measured to add only fragmentation noise); the
1609+
/// random-walk stationary distribution method is deferred to a later
1610+
/// increment. Deterministic: no randomness; built purely from shipped tree
1611+
/// fields + [`Self::dist`].
1612+
///
1613+
/// This convenience wrapper uses the default
1614+
/// [`ENSEMBLE_GRAPH_BUDGET`](Self::ENSEMBLE_GRAPH_BUDGET), so it never runs the
1615+
/// quadratic overlap build on production-sized corpora — it degrades to the
1616+
/// linear path-minority signal above the budget. Call
1617+
/// [`ensemble_anomaly_scores_budgeted`](Self::ensemble_anomaly_scores_budgeted)
1618+
/// to choose the cap explicitly.
1619+
pub fn ensemble_anomaly_scores(&self, data: &[u8], vec_len: usize) -> Vec<AnomalyScore> {
1620+
self.ensemble_anomaly_scores_budgeted(data, vec_len, Self::ENSEMBLE_GRAPH_BUDGET)
1621+
}
1622+
1623+
/// See [`ensemble_anomaly_scores`](Self::ensemble_anomaly_scores). `graph_budget`
1624+
/// caps the leaf count above which the quadratic connected-component term is
1625+
/// skipped (path-minority only). `usize::MAX` always includes it; `0` forces
1626+
/// path-only.
1627+
pub fn ensemble_anomaly_scores_budgeted(
1628+
&self, data: &[u8], vec_len: usize, graph_budget: usize,
1629+
) -> Vec<AnomalyScore> {
1630+
let count = data.len() / vec_len;
1631+
1632+
let leaves: Vec<usize> = self
1633+
.nodes
1634+
.iter()
1635+
.enumerate()
1636+
.filter(|(_, n)| n.is_leaf())
1637+
.map(|(i, _)| i)
1638+
.collect();
1639+
let n_leaves = leaves.len();
1640+
if n_leaves == 0 {
1641+
return Vec::new();
1642+
}
1643+
1644+
// Parent map (the tree stores child pointers, not parent pointers).
1645+
let mut parent = vec![usize::MAX; self.nodes.len()];
1646+
for (i, n) in self.nodes.iter().enumerate() {
1647+
if let Some(l) = n.left {
1648+
parent[l] = i;
1649+
}
1650+
if let Some(r) = n.right {
1651+
parent[r] = i;
1652+
}
1653+
}
1654+
1655+
// Signal 1 — parent-child path-minority ratio (always; O(L · depth)).
1656+
let mut s_path = vec![0.0f64; n_leaves];
1657+
for (a, &leaf) in leaves.iter().enumerate() {
1658+
let mut node = leaf;
1659+
let mut min_ratio = 1.0f64;
1660+
while parent[node] != usize::MAX {
1661+
let p = parent[node];
1662+
let ratio = self.nodes[node].cardinality as f64 / (self.nodes[p].cardinality as f64).max(1.0);
1663+
if ratio < min_ratio {
1664+
min_ratio = ratio;
1665+
}
1666+
node = p;
1667+
}
1668+
s_path[a] = 1.0 - min_ratio;
1669+
}
1670+
1671+
// Signal 2 — connected-component cardinality over the leaf-overlap graph.
1672+
// Guarded: the overlap build is O(L² · vec_len), so it is skipped above
1673+
// `graph_budget` and scoring falls back to path-minority alone.
1674+
let s_comp: Option<Vec<f64>> = if n_leaves <= graph_budget {
1675+
let center = |node_idx: usize| -> &[u8] {
1676+
let ci = self.nodes[node_idx].center_idx;
1677+
&data[ci * vec_len..(ci + 1) * vec_len]
1678+
};
1679+
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n_leaves];
1680+
for a in 0..n_leaves {
1681+
let na = &self.nodes[leaves[a]];
1682+
let ca = center(leaves[a]);
1683+
for b in (a + 1)..n_leaves {
1684+
let nb = &self.nodes[leaves[b]];
1685+
let d = self.dist(ca, center(leaves[b]));
1686+
if d <= na.radius.saturating_add(nb.radius) {
1687+
adj[a].push(b);
1688+
adj[b].push(a);
1689+
}
1690+
}
1691+
}
1692+
let mut comp_of = vec![usize::MAX; n_leaves];
1693+
let mut comp_size: Vec<usize> = Vec::new();
1694+
for start in 0..n_leaves {
1695+
if comp_of[start] != usize::MAX {
1696+
continue;
1697+
}
1698+
let cid = comp_size.len();
1699+
let mut stack = vec![start];
1700+
comp_of[start] = cid;
1701+
let mut size = 0usize;
1702+
while let Some(v) = stack.pop() {
1703+
size += 1;
1704+
for &w in &adj[v] {
1705+
if comp_of[w] == usize::MAX {
1706+
comp_of[w] = cid;
1707+
stack.push(w);
1708+
}
1709+
}
1710+
}
1711+
comp_size.push(size);
1712+
}
1713+
let max_comp = comp_size.iter().copied().max().unwrap_or(1).max(1) as f64;
1714+
Some(
1715+
(0..n_leaves)
1716+
.map(|a| 1.0 - comp_size[comp_of[a]] as f64 / max_comp)
1717+
.collect(),
1718+
)
1719+
} else {
1720+
None
1721+
};
1722+
1723+
// Combine: average whichever signals are available.
1724+
let leaf_score: Vec<f64> = match &s_comp {
1725+
Some(sc) => (0..n_leaves).map(|a| (s_path[a] + sc[a]) / 2.0).collect(),
1726+
None => s_path,
1727+
};
1728+
1729+
// Project leaf scores back onto every original data point.
1730+
let mut out: Vec<AnomalyScore> = (0..count)
1731+
.map(|index| AnomalyScore {
1732+
index,
1733+
lfd: 0.0,
1734+
score: 0.0,
1735+
awareness: AwarenessState::Crystallized,
1736+
})
1737+
.collect();
1738+
for (a, &node_idx) in leaves.iter().enumerate() {
1739+
let node = &self.nodes[node_idx];
1740+
let start = node.offset;
1741+
let end = start + node.cardinality;
1742+
let score = leaf_score[a];
1743+
let awareness = if score < 0.25 {
1744+
AwarenessState::Crystallized
1745+
} else if score < 0.50 {
1746+
AwarenessState::Tensioned
1747+
} else if score < 0.75 {
1748+
AwarenessState::Uncertain
1749+
} else {
1750+
AwarenessState::Noise
1751+
};
1752+
for &orig_idx in &self.reordered[start..end] {
1753+
if orig_idx < count {
1754+
out[orig_idx] = AnomalyScore {
1755+
index: orig_idx,
1756+
lfd: node.lfd.value,
1757+
score,
1758+
awareness,
1759+
};
1760+
}
1761+
}
1762+
}
1763+
out
1764+
}
15791765
}
15801766

15811767
// ─── Tests ──────────────────────────────────────────
@@ -2670,6 +2856,86 @@ mod tests {
26702856
assert!(auc > 0.5, "anomaly signal is not better than chance (AUC={auc:.4})");
26712857
}
26722858

2859+
/// ROC-AUC via the Mann-Whitney U statistic (ties count 0.5); positive class
2860+
/// = `is_pos(index)`.
2861+
fn roc_auc(scores: &[AnomalyScore], is_pos: impl Fn(usize) -> bool) -> f64 {
2862+
let (mut u, mut n_pos) = (0.0f64, 0usize);
2863+
for a in scores {
2864+
if !is_pos(a.index) {
2865+
continue;
2866+
}
2867+
n_pos += 1;
2868+
for b in scores {
2869+
if is_pos(b.index) {
2870+
continue;
2871+
}
2872+
if a.score > b.score {
2873+
u += 1.0;
2874+
} else if (a.score - b.score).abs() < 1e-12 {
2875+
u += 0.5;
2876+
}
2877+
}
2878+
}
2879+
let n_neg = scores.len() - n_pos;
2880+
if n_pos == 0 || n_neg == 0 {
2881+
return 0.5;
2882+
}
2883+
u / (n_pos as f64 * n_neg as f64)
2884+
}
2885+
2886+
/// `D-GEN-CHAODA-ENSEMBLE` increment 1: the isolation-sensitive ensemble must
2887+
/// materially out-discriminate the single-method leaf-LFD baseline on the same
2888+
/// synthetic mixture the spike measured at AUC ≈ 0.62. This is a NEW capability
2889+
/// (not a future improvement), so a lower-bound gate is appropriate here.
2890+
#[test]
2891+
fn test_chaoda_ensemble_beats_single_lfd_on_genetics_like_mixture() {
2892+
let (data, outliers) = make_genetics_like_mixture();
2893+
let tree = ClamTree::build(&data, SPIKE_VEC_LEN, 3);
2894+
let is_out = |i: usize| outliers.contains(&i);
2895+
2896+
let lfd = tree.anomaly_scores(&data, SPIKE_VEC_LEN);
2897+
let ens = tree.ensemble_anomaly_scores(&data, SPIKE_VEC_LEN);
2898+
// Path-minority only (graph_budget = 0 forces the linear fallback that the
2899+
// public API uses above ENSEMBLE_GRAPH_BUDGET) — grounds the claim that the
2900+
// dominant signal survives without the quadratic component term.
2901+
let path_only = tree.ensemble_anomaly_scores_budgeted(&data, SPIKE_VEC_LEN, 0);
2902+
assert_eq!(ens.len(), lfd.len());
2903+
for s in &ens {
2904+
assert!(s.score >= 0.0 && s.score <= 1.0, "ensemble score out of range");
2905+
}
2906+
2907+
let auc_lfd = roc_auc(&lfd, is_out);
2908+
let auc_ens = roc_auc(&ens, is_out);
2909+
let auc_path = roc_auc(&path_only, is_out);
2910+
eprintln!(
2911+
"[CHAODA-ensemble] AUC single-LFD={auc_lfd:.4} path-only={auc_path:.4} ensemble={auc_ens:.4} lift={:.4}",
2912+
auc_ens - auc_lfd
2913+
);
2914+
2915+
// The linear path-only fallback (used at scale) must itself clear the bar,
2916+
// otherwise the budget guard would silently degrade production accuracy.
2917+
assert!(
2918+
auc_path >= 0.85,
2919+
"path-only fallback AUC {auc_path:.4} below 0.85 — the budget guard would degrade large corpora"
2920+
);
2921+
2922+
// Determinism: the ensemble graph is built purely from shipped tree
2923+
// fields, so a rebuild must reproduce bit-identical scores.
2924+
let tree2 = ClamTree::build(&data, SPIKE_VEC_LEN, 3);
2925+
let ens2 = tree2.ensemble_anomaly_scores(&data, SPIKE_VEC_LEN);
2926+
for (a, b) in ens.iter().zip(ens2.iter()) {
2927+
assert_eq!(a.score.to_bits(), b.score.to_bits(), "non-deterministic ensemble score");
2928+
}
2929+
2930+
// The whole point: the ensemble lifts discrimination well past the weak
2931+
// single-LFD signal. These are lower bounds (a better ensemble keeps them green).
2932+
assert!(
2933+
auc_ens > auc_lfd + 0.15,
2934+
"ensemble (AUC={auc_ens:.4}) did not materially beat single-LFD (AUC={auc_lfd:.4})"
2935+
);
2936+
assert!(auc_ens >= 0.85, "ensemble AUC {auc_ens:.4} did not clear the PROBE-CHAODA-1000G bar of 0.85");
2937+
}
2938+
26732939
// ── rho_nn_candidates tests ──────────────────────────────────
26742940

26752941
#[test]

0 commit comments

Comments
 (0)