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:
parent
989635e7b0
commit
a98de6f8f4
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue