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