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.
This commit is contained in:
CarterPerez-dev 2026-04-29 00:55:58 -04:00
parent 989635e7b0
commit a98de6f8f4
4 changed files with 236 additions and 0 deletions

View File

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

View File

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

View File

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