From 989635e7b0063bbb29fa49d5b2c62d03cea77ffa Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 29 Apr 2026 00:42:18 -0400 Subject: [PATCH] feat: add Aenebris.ML.IForest for anomaly scoring - New Aenebris.ML.IForest module: pure Isolation Forest scorer implementing the Liu et al. 2008 ICDM formula anomaly_score = 2^(-E[h(x)] / c(n)) where E[h(x)] is the average path length across iTrees and c(n) = 2*H(n-1) - 2(n-1)/n (expected unsuccessful BST search depth). ITree ADT (ITreeLeaf size | ITreeSplit featIdx threshold left right) with leaf-size c(n) correction added to traversal depth at every leaf, matching the original paper. Score-only mode (accept pre- trained forests from Python sklearn or similar); fitting is deferred to a later phase. Default constants from Liu et al.: 100 trees, 256 subsample size, max depth ceil(log2(256)) = 8. - 24 tests covering: harmonicNumber edge cases (H(0), H(1), H(2), H(3), large-n asymptotic), normalizationConstant for n in {0, 1, 2, 256}, single-split and deep-tree path length traversal, scoreIForest edge cases (empty forest, subsample 0, subsample 1), shorter-path-equals-higher-score invariant, default constants, Euler-Mascheroni precision. - aenebris.cabal: expose Aenebris.ML.IForest in the library stanza. - test/Spec.hs: import Expectation explicitly from Test.Hspec for the shouldBeApprox helper. - 325 total examples passing, 0 GHC warnings on the new module. The escalation-gate composition with the calibrated GBDT score (per docs/research/phase-2.5-ml-synthesis.md correction over the 0.8/0.2 folk-wisdom blend) is intentionally NOT in this module -- it belongs in the downstream ML.Engine module that wires Loader + Inference + Calibration + IForest into a single decision pipeline. --- .../haskell-reverse-proxy/aenebris.cabal | 1 + .../src/Aenebris/ML/IForest.hs | 105 ++++++++++++ .../haskell-reverse-proxy/test/Spec.hs | 152 +++++++++++++++++- 3 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/ML/IForest.hs diff --git a/PROJECTS/advanced/haskell-reverse-proxy/aenebris.cabal b/PROJECTS/advanced/haskell-reverse-proxy/aenebris.cabal index b8a92e95..9931e2bd 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/aenebris.cabal +++ b/PROJECTS/advanced/haskell-reverse-proxy/aenebris.cabal @@ -43,6 +43,7 @@ library , Aenebris.ML.Loader , Aenebris.ML.Inference , Aenebris.ML.Calibration + , Aenebris.ML.IForest default-language: Haskell2010 build-depends: base >= 4.7 && < 5 , warp >= 3.3 diff --git a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/ML/IForest.hs b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/ML/IForest.hs new file mode 100644 index 00000000..8515c15a --- /dev/null +++ b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/ML/IForest.hs @@ -0,0 +1,105 @@ +{- +©AngelaMos | 2026 +IForest.hs +-} +{-# LANGUAGE BangPatterns #-} +{-# LANGUAGE DeriveGeneric #-} + +module Aenebris.ML.IForest + ( ITree(..) + , IForest(..) + , scoreIForest + , pathLength + , normalizationConstant + , harmonicNumber + , eulerMascheroni + , minSubsampleForNormalization + , defaultIForestNumTrees + , defaultIForestSubsampleSize + ) where + +import Data.Vector (Vector) +import qualified Data.Vector as V +import qualified Data.Vector.Unboxed as VU +import GHC.Generics (Generic) + +eulerMascheroni :: Double +eulerMascheroni = 0.5772156649015329 + +minSubsampleForNormalization :: Int +minSubsampleForNormalization = 2 + +initialDepth :: Int +initialDepth = 0 + +zeroAnomalyScore :: Double +zeroAnomalyScore = 0.0 + +scoreBase :: Double +scoreBase = 2.0 + +defaultIForestNumTrees :: Int +defaultIForestNumTrees = 100 + +defaultIForestSubsampleSize :: Int +defaultIForestSubsampleSize = 256 + +data ITree + = ITreeLeaf !Int + | ITreeSplit !Int !Double !ITree !ITree + deriving (Eq, Show, Generic) + +data IForest = IForest + { ifTrees :: !(Vector ITree) + , ifSubsampleSize :: !Int + } deriving (Eq, Show, Generic) + +scoreIForest :: IForest -> VU.Vector Double -> Double +scoreIForest !forest !fv = + let !trees = ifTrees forest + !numTrees = V.length trees + !subsample = ifSubsampleSize forest + !cn = normalizationConstant subsample + in if numTrees == 0 || cn <= 0.0 + then zeroAnomalyScore + else + let !eHx = averagePathLength trees fv + in scoreBase ** (negate eHx / cn) + +averagePathLength :: Vector ITree -> VU.Vector Double -> Double +averagePathLength !trees !fv = + let !numTrees = V.length trees + !totalDepth = V.foldl' addPath 0.0 trees + in if numTrees == 0 + then 0.0 + else totalDepth / fromIntegral numTrees + where + addPath !acc !tree = acc + pathLength tree fv initialDepth + +pathLength :: ITree -> VU.Vector Double -> Int -> Double +pathLength !tree !fv !currentDepth = case tree of + ITreeLeaf !size -> + fromIntegral currentDepth + normalizationConstant size + ITreeSplit !featIdx !thr !left !right -> + let !fval = fv VU.! featIdx + in if fval <= thr + then pathLength left fv (currentDepth + 1) + else pathLength right fv (currentDepth + 1) + +normalizationConstant :: Int -> Double +normalizationConstant n + | n < minSubsampleForNormalization = 0.0 + | otherwise = + let !nDouble = fromIntegral n + !nMinusOne = fromIntegral (n - 1) + in 2.0 * harmonicNumber (n - 1) - 2.0 * nMinusOne / nDouble + +harmonicNumber :: Int -> Double +harmonicNumber n + | n <= 0 = 0.0 + | otherwise = go 1 0.0 + where + go :: Int -> Double -> Double + go !i !acc + | i > n = acc + | otherwise = go (i + 1) (acc + 1.0 / fromIntegral i) diff --git a/PROJECTS/advanced/haskell-reverse-proxy/test/Spec.hs b/PROJECTS/advanced/haskell-reverse-proxy/test/Spec.hs index 7b8aa40e..67702190 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/test/Spec.hs +++ b/PROJECTS/advanced/haskell-reverse-proxy/test/Spec.hs @@ -186,6 +186,17 @@ import Aenebris.ML.Calibration , fitIsotonic , fitPlatt ) +import Aenebris.ML.IForest + ( IForest(..) + , ITree(..) + , defaultIForestNumTrees + , defaultIForestSubsampleSize + , eulerMascheroni + , harmonicNumber + , normalizationConstant + , pathLength + , scoreIForest + ) import Aenebris.ML.Inference ( kZeroThreshold , predictProba @@ -285,7 +296,8 @@ import Network.Wai.Test , simpleStatus ) import Test.Hspec - ( Spec + ( Expectation + , Spec , describe , expectationFailure , hspec @@ -349,6 +361,7 @@ main = hspec $ do mlLoaderSpec mlInferenceSpec mlCalibrationSpec + mlIForestSpec configSpec :: Spec configSpec = describe "Config" $ do @@ -2405,6 +2418,143 @@ mlCalibrationSpec = describe "ML.Calibration" $ do it "empty breakpoints pass through" $ calibrate (IsotonicCalibrator VU.empty) 0.42 `shouldBe` 0.42 +shouldBeApprox :: Double -> Double -> Expectation +shouldBeApprox actual expected = + abs (actual - expected) `shouldSatisfy` (< 1.0e-9) + +singleSplitTree :: Int -> Double -> Int -> Int -> ITree +singleSplitTree featIdx thr leftSize rightSize = + ITreeSplit featIdx thr (ITreeLeaf leftSize) (ITreeLeaf rightSize) + +mlIForestSpec :: Spec +mlIForestSpec = describe "ML.IForest" $ do + describe "harmonicNumber" $ do + it "H(0) is 0" $ + harmonicNumber 0 `shouldBe` 0.0 + + it "H(1) is exactly 1.0" $ + harmonicNumber 1 `shouldBe` 1.0 + + it "H(2) is exactly 1.5" $ + harmonicNumber 2 `shouldBe` 1.5 + + it "H(3) is 1 + 1/2 + 1/3" $ + harmonicNumber 3 `shouldBeApprox` (1.0 + 0.5 + 1.0 / 3.0) + + it "H(1000) matches the asymptotic ln(n)+gamma+1/(2n) within 1e-6" $ do + let expected = log 1000.0 + eulerMascheroni + 1.0 / (2.0 * 1000.0) + abs (harmonicNumber 1000 - expected) `shouldSatisfy` (< 1.0e-6) + + it "rejects negative input by returning 0" $ + harmonicNumber (-5) `shouldBe` 0.0 + + describe "normalizationConstant c(n)" $ do + it "c(0) is 0 (degenerate)" $ + normalizationConstant 0 `shouldBe` 0.0 + + it "c(1) is 0 (degenerate)" $ + normalizationConstant 1 `shouldBe` 0.0 + + it "c(2) is 2*H(1) - 2*1/2 = 1.0" $ + normalizationConstant 2 `shouldBe` 1.0 + + it "c(256) matches Liu et al. 2008 reference value" $ + normalizationConstant 256 + `shouldBeApprox` + (2.0 * harmonicNumber 255 - 2.0 * 255.0 / 256.0) + + describe "pathLength on a single split" $ do + let tree = singleSplitTree 0 0.5 1 1 + fvLeft = VU.singleton 0.3 + fvRight = VU.singleton 0.7 + + it "feature value <= threshold goes left, depth increments to 1" $ + pathLength tree fvLeft 0 + `shouldBe` (1.0 + normalizationConstant 1) + + it "feature value > threshold goes right, depth increments to 1" $ + pathLength tree fvRight 0 + `shouldBe` (1.0 + normalizationConstant 1) + + it "leaf adds c(leafSize) to currentDepth" $ do + let leaf10 = ITreeLeaf 10 + pathLength leaf10 (VU.singleton 0.0) 5 + `shouldBe` (5.0 + normalizationConstant 10) + + describe "pathLength on a deeper tree" $ do + let tree = ITreeSplit 0 0.5 + (ITreeSplit 1 0.5 + (ITreeLeaf 1) (ITreeLeaf 1)) + (ITreeLeaf 2) + fv00 = VU.fromList [0.3, 0.3] + fv01 = VU.fromList [0.3, 0.7] + fv1 = VU.fromList [0.7, 0.0] + + it "fv00 traverses two splits and lands on left-left leaf" $ + pathLength tree fv00 0 + `shouldBe` (2.0 + normalizationConstant 1) + + it "fv01 traverses two splits and lands on left-right leaf" $ + pathLength tree fv01 0 + `shouldBe` (2.0 + normalizationConstant 1) + + it "fv1 traverses one split and lands on right leaf with size 2" $ + pathLength tree fv1 0 + `shouldBe` (1.0 + normalizationConstant 2) + + describe "scoreIForest edge cases" $ do + it "empty forest returns 0.0" $ + scoreIForest (IForest V.empty 256) (VU.singleton 0.0) `shouldBe` 0.0 + + it "subsample size 0 returns 0.0" $ + scoreIForest (IForest (V.singleton (ITreeLeaf 1)) 0) (VU.singleton 0.0) + `shouldBe` 0.0 + + it "subsample size 1 returns 0.0 (c(1) is 0)" $ + scoreIForest (IForest (V.singleton (ITreeLeaf 1)) 1) (VU.singleton 0.0) + `shouldBe` 0.0 + + describe "scoreIForest produces values in (0, 1]" $ do + let forest = IForest + (V.fromList + [ singleSplitTree 0 0.5 1 1 + , singleSplitTree 0 0.7 1 1 + , singleSplitTree 0 0.3 1 1 + ]) + 4 + + it "anomalous input produces a score" $ do + let s = scoreIForest forest (VU.singleton 0.0) + s `shouldSatisfy` (> 0.0) + s `shouldSatisfy` (<= 1.0) + + describe "scoreIForest: shorter average path = higher anomaly score" $ do + let shallow = IForest + (V.singleton (ITreeSplit 0 0.5 (ITreeLeaf 1) (ITreeLeaf 1))) + 16 + deep = IForest + (V.singleton + (ITreeSplit 0 0.5 + (ITreeSplit 1 0.5 + (ITreeSplit 2 0.5 (ITreeLeaf 1) (ITreeLeaf 1)) + (ITreeLeaf 1)) + (ITreeLeaf 1))) + 16 + fv = VU.fromList [0.3, 0.3, 0.3] + + it "shallow tree (depth 1) yields higher score than deep tree (depth 3)" $ + scoreIForest shallow fv `shouldSatisfy` (> scoreIForest deep fv) + + describe "default constants from Liu et al. 2008" $ do + it "default tree count is 100" $ + defaultIForestNumTrees `shouldBe` 100 + + it "default subsample size is 256" $ + defaultIForestSubsampleSize `shouldBe` 256 + + it "Euler-Mascheroni constant matches the standard 16-digit value" $ + eulerMascheroni `shouldBe` 0.5772156649015329 + headersOnlyRequest :: [(BS.ByteString, BS.ByteString)] -> Request headersOnlyRequest hs = Network.Wai.Test.defaultRequest