From a98de6f8f403ca9c430575e8add0c4a8230e9295 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 29 Apr 2026 00:55:58 -0400 Subject: [PATCH] feat: add Aenebris.ML.Engine decision pipeline - New Aenebris.ML.Engine module: pure decision pipeline composing the previously-built Loader, Inference, Calibration, and IForest modules into a single per-request scoring path. Engine record holds (Ensemble, Calibrator, Maybe IForest, EngineConfig); runEngine takes a feature vector and produces DecisionDetails (Decision, raw proba, calibrated proba, optional IForest score). The Decision is one of DecisionHuman, DecisionBot, or DecisionChallenge. - Implements the escalation-gate semantics per the 2026 research correction (over the deprecated 0.8/0.2 weighted blend): calibrated <= humanThreshold -> DecisionHuman calibrated >= botThreshold -> DecisionBot otherwise (ambiguous band): if IForest configured and ifScore >= escalation threshold -> DecisionBot (escalation) else if challenges enabled -> DecisionChallenge else -> DecisionHuman (fallthrough) Defaults: 0.3 / 0.7 thresholds, 0.6 IForest escalation, challenges on by default. - 17 tests covering: defaultEngineConfig values, decision boundaries with NoCalibrator and no IForest (low/mid/high leaf ensembles), ambiguous-band escalation via low-vs-high anomaly IForests, ambiguous-band with challenges disabled (fallthrough to Human or escalation to Bot), Platt calibrator pulling decisions across thresholds, and DecisionDetails field correctness. - aenebris.cabal: expose Aenebris.ML.Engine in the library stanza. - 342 total examples passing, 0 GHC warnings on the new module. The Wai middleware wiring (extract features -> runEngine -> route by Decision) is the next module (ML.Middleware); Engine intentionally stays pure / independent of HTTP machinery so it can be unit-tested without request fixtures. --- .../haskell-reverse-proxy/aenebris.cabal | 1 + .../src/Aenebris/ML/Engine.hs | 119 ++++++++++++++++++ .../haskell-reverse-proxy/test/Spec.hs | 116 +++++++++++++++++ ...ial-enumeration.nimble => credenum.nimble} | 0 4 files changed, 236 insertions(+) create mode 100644 PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/ML/Engine.hs rename PROJECTS/intermediate/credential-enumeration/{credential-enumeration.nimble => credenum.nimble} (100%) diff --git a/PROJECTS/advanced/haskell-reverse-proxy/aenebris.cabal b/PROJECTS/advanced/haskell-reverse-proxy/aenebris.cabal index 9931e2bd..00aa1c34 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/aenebris.cabal +++ b/PROJECTS/advanced/haskell-reverse-proxy/aenebris.cabal @@ -44,6 +44,7 @@ library , Aenebris.ML.Inference , Aenebris.ML.Calibration , Aenebris.ML.IForest + , Aenebris.ML.Engine default-language: Haskell2010 build-depends: base >= 4.7 && < 5 , warp >= 3.3 diff --git a/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/ML/Engine.hs b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/ML/Engine.hs new file mode 100644 index 00000000..34029001 --- /dev/null +++ b/PROJECTS/advanced/haskell-reverse-proxy/src/Aenebris/ML/Engine.hs @@ -0,0 +1,119 @@ +{- +©AngelaMos | 2026 +Engine.hs +-} +{-# LANGUAGE BangPatterns #-} +{-# LANGUAGE DeriveGeneric #-} + +module Aenebris.ML.Engine + ( Engine(..) + , EngineConfig(..) + , Decision(..) + , DecisionDetails(..) + , defaultEngineConfig + , makeEngine + , runEngine + , runEngineDecision + ) where + +import qualified Data.Vector.Unboxed as VU +import GHC.Generics (Generic) + +import Aenebris.ML.Calibration + ( Calibrator(..) + , calibrate + ) +import Aenebris.ML.IForest + ( IForest + , scoreIForest + ) +import Aenebris.ML.Inference + ( predictProba + ) +import Aenebris.ML.Model + ( Ensemble + ) + +defaultHumanThreshold :: Double +defaultHumanThreshold = 0.3 + +defaultBotThreshold :: Double +defaultBotThreshold = 0.7 + +defaultIForestEscalation :: Double +defaultIForestEscalation = 0.6 + +defaultChallengeOnAmbiguous :: Bool +defaultChallengeOnAmbiguous = True + +data Decision + = DecisionHuman + | DecisionBot + | DecisionChallenge + deriving (Eq, Show, Generic) + +data EngineConfig = EngineConfig + { ecHumanThreshold :: !Double + , ecBotThreshold :: !Double + , ecIForestEscalation :: !Double + , ecChallengeOnAmbiguous :: !Bool + } deriving (Eq, Show, Generic) + +defaultEngineConfig :: EngineConfig +defaultEngineConfig = EngineConfig + { ecHumanThreshold = defaultHumanThreshold + , ecBotThreshold = defaultBotThreshold + , ecIForestEscalation = defaultIForestEscalation + , ecChallengeOnAmbiguous = defaultChallengeOnAmbiguous + } + +data Engine = Engine + { engineEnsemble :: !Ensemble + , engineCalibrator :: !Calibrator + , engineIForest :: !(Maybe IForest) + , engineConfig :: !EngineConfig + } + +data DecisionDetails = DecisionDetails + { ddDecision :: !Decision + , ddRawProba :: !Double + , ddCalibrated :: !Double + , ddIForestScore :: !(Maybe Double) + } deriving (Eq, Show, Generic) + +makeEngine + :: Ensemble + -> Calibrator + -> Maybe IForest + -> EngineConfig + -> Engine +makeEngine = Engine + +runEngine :: Engine -> VU.Vector Double -> DecisionDetails +runEngine !eng !fv = + let !raw = predictProba (engineEnsemble eng) fv + !calibrated = calibrate (engineCalibrator eng) raw + !mIfScore = fmap (\f -> scoreIForest f fv) (engineIForest eng) + !decision = decideOutcome (engineConfig eng) calibrated mIfScore + in DecisionDetails + { ddDecision = decision + , ddRawProba = raw + , ddCalibrated = calibrated + , ddIForestScore = mIfScore + } + +runEngineDecision :: Engine -> VU.Vector Double -> Decision +runEngineDecision eng fv = ddDecision (runEngine eng fv) + +decideOutcome :: EngineConfig -> Double -> Maybe Double -> Decision +decideOutcome !cfg !calibrated !mIfScore + | calibrated <= ecHumanThreshold cfg = DecisionHuman + | calibrated >= ecBotThreshold cfg = DecisionBot + | otherwise = + case mIfScore of + Just ifScore | ifScore >= ecIForestEscalation cfg -> + DecisionBot + _ -> + if ecChallengeOnAmbiguous cfg + then DecisionChallenge + else DecisionHuman diff --git a/PROJECTS/advanced/haskell-reverse-proxy/test/Spec.hs b/PROJECTS/advanced/haskell-reverse-proxy/test/Spec.hs index 67702190..acf0730b 100644 --- a/PROJECTS/advanced/haskell-reverse-proxy/test/Spec.hs +++ b/PROJECTS/advanced/haskell-reverse-proxy/test/Spec.hs @@ -186,6 +186,16 @@ import Aenebris.ML.Calibration , fitIsotonic , fitPlatt ) +import Aenebris.ML.Engine + ( Decision(..) + , DecisionDetails(..) + , Engine(..) + , EngineConfig(..) + , defaultEngineConfig + , makeEngine + , runEngine + , runEngineDecision + ) import Aenebris.ML.IForest ( IForest(..) , ITree(..) @@ -362,6 +372,7 @@ main = hspec $ do mlInferenceSpec mlCalibrationSpec mlIForestSpec + mlEngineSpec configSpec :: Spec configSpec = describe "Config" $ do @@ -2555,6 +2566,111 @@ mlIForestSpec = describe "ML.IForest" $ do it "Euler-Mascheroni constant matches the standard 16-digit value" $ eulerMascheroni `shouldBe` 0.5772156649015329 +mlEngineSpec :: Spec +mlEngineSpec = describe "ML.Engine" $ do + let humanLeafEnsemble = binaryEnsemble [makeLeafTree (-5.0)] + botLeafEnsemble = binaryEnsemble [makeLeafTree 5.0] + midLeafEnsemble = binaryEnsemble [makeLeafTree 0.0] + + cfg = defaultEngineConfig + eng e cal mIf = makeEngine e cal mIf cfg + + lowAnomalyForest = IForest (V.singleton (ITreeLeaf 256)) 256 + highAnomalyForest = IForest (V.singleton (ITreeLeaf 1)) 256 + + describe "defaultEngineConfig" $ do + it "uses sensible defaults" $ do + ecHumanThreshold cfg `shouldBe` 0.3 + ecBotThreshold cfg `shouldBe` 0.7 + ecIForestEscalation cfg `shouldBe` 0.6 + ecChallengeOnAmbiguous cfg `shouldBe` True + + describe "decision boundaries with NoCalibrator and no IForest" $ do + it "very negative leaf (proba ~ 0.0067) routes to DecisionHuman" $ + runEngineDecision (eng humanLeafEnsemble NoCalibrator Nothing) (singletonFv 0.0) + `shouldBe` DecisionHuman + + it "very positive leaf (proba ~ 0.993) routes to DecisionBot" $ + runEngineDecision (eng botLeafEnsemble NoCalibrator Nothing) (singletonFv 0.0) + `shouldBe` DecisionBot + + it "midpoint leaf (proba = 0.5) lands in ambiguous band -> DecisionChallenge" $ + runEngineDecision (eng midLeafEnsemble NoCalibrator Nothing) (singletonFv 0.0) + `shouldBe` DecisionChallenge + + describe "ambiguous band escalation via IForest" $ do + it "low-anomaly IForest (score 0.5 < 0.6 escalation) keeps DecisionChallenge" $ + runEngineDecision + (eng midLeafEnsemble NoCalibrator (Just lowAnomalyForest)) + (singletonFv 0.0) + `shouldBe` DecisionChallenge + + it "high-anomaly IForest (score 1.0 >= 0.6 escalation) escalates to DecisionBot" $ + runEngineDecision + (eng midLeafEnsemble NoCalibrator (Just highAnomalyForest)) + (singletonFv 0.0) + `shouldBe` DecisionBot + + it "DecisionDetails records the IF score when present" $ + ddIForestScore + (runEngine (eng midLeafEnsemble NoCalibrator (Just highAnomalyForest)) + (singletonFv 0.0)) + `shouldBe` Just 1.0 + + it "DecisionDetails records Nothing for IF score when absent" $ + ddIForestScore + (runEngine (eng midLeafEnsemble NoCalibrator Nothing) (singletonFv 0.0)) + `shouldBe` Nothing + + describe "ambiguous band with challenges disabled" $ do + let noChallengeCfg = cfg { ecChallengeOnAmbiguous = False } + engNoChal e cal mIf = makeEngine e cal mIf noChallengeCfg + + it "ambiguous calibrated + no IForest falls through to DecisionHuman" $ + runEngineDecision + (engNoChal midLeafEnsemble NoCalibrator Nothing) + (singletonFv 0.0) + `shouldBe` DecisionHuman + + it "ambiguous calibrated + low-anomaly IForest still falls through to DecisionHuman" $ + runEngineDecision + (engNoChal midLeafEnsemble NoCalibrator (Just lowAnomalyForest)) + (singletonFv 0.0) + `shouldBe` DecisionHuman + + it "ambiguous calibrated + high-anomaly IForest escalates to DecisionBot" $ + runEngineDecision + (engNoChal midLeafEnsemble NoCalibrator (Just highAnomalyForest)) + (singletonFv 0.0) + `shouldBe` DecisionBot + + describe "calibrator changes the decision threshold" $ do + let almostBotEnsemble = binaryEnsemble [makeLeafTree 1.5] + rawProba = 1.0 / (1.0 + exp (-1.5)) + + it "without calibration, raw proba in (0.7, 0.99) -> DecisionBot" $ do + let result = runEngine (eng almostBotEnsemble NoCalibrator Nothing) (singletonFv 0.0) + ddRawProba result `shouldBe` rawProba + ddCalibrated result `shouldBe` rawProba + ddDecision result `shouldBe` DecisionBot + + it "Platt with strongly negative a*p+b pulls calibrated below human threshold" $ do + let cal = PlattCalibrator (-100.0) 100.0 + result = runEngine (eng almostBotEnsemble cal Nothing) (singletonFv 0.0) + ddCalibrated result `shouldSatisfy` (< 0.3) + ddDecision result `shouldBe` DecisionHuman + + describe "DecisionDetails mirrors all four fields" $ do + let result = runEngine (eng midLeafEnsemble NoCalibrator Nothing) (singletonFv 0.0) + it "ddDecision reflects the routing" $ + ddDecision result `shouldBe` DecisionChallenge + it "ddRawProba is predictProba output (0.5 for leaf=0)" $ + ddRawProba result `shouldBe` 0.5 + it "ddCalibrated equals ddRawProba under NoCalibrator" $ + ddCalibrated result `shouldBe` 0.5 + it "ddIForestScore is Nothing when no IForest configured" $ + ddIForestScore result `shouldBe` Nothing + headersOnlyRequest :: [(BS.ByteString, BS.ByteString)] -> Request headersOnlyRequest hs = Network.Wai.Test.defaultRequest diff --git a/PROJECTS/intermediate/credential-enumeration/credential-enumeration.nimble b/PROJECTS/intermediate/credential-enumeration/credenum.nimble similarity index 100% rename from PROJECTS/intermediate/credential-enumeration/credential-enumeration.nimble rename to PROJECTS/intermediate/credential-enumeration/credenum.nimble