packages feed

bloodhound-1.0.0.0: tests/Test/KnnTrainSpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Test.KnnTrainSpec (spec) where

import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Char8 qualified as BS
import Data.ByteString.Lazy.Char8 qualified as LBS
import Database.Bloodhound.OpenSearch3.Requests qualified as OS3Requests
import Database.Bloodhound.OpenSearch3.Types
import TestsUtils.Import
import Prelude

-- | A representative @KnnTrainingRequest@ used by the endpoint-shape tests
-- in both the 'trainKnnModel' and 'trainKnnModelWith' describe blocks.
sampleReq :: KnnTrainingRequest
sampleReq =
  KnnTrainingRequest
    { knnTrainingRequestIndex = [qqIndexName|train-index-name|],
      knnTrainingRequestField = FieldName "train-field-name",
      knnTrainingRequestDimension = 16,
      knnTrainingRequestMethod =
        KnnTrainingMethod
          { knnTrainingMethodName = "ivf",
            knnTrainingMethodEngine = KnnEngineFaiss,
            knnTrainingMethodSpaceType = Just "l2",
            knnTrainingMethodParameters =
              Just (object ["nlist" .= (128 :: Int)])
          },
      knnTrainingRequestSpaceType = Nothing,
      knnTrainingRequestMaxTrainingVectorCount = Nothing,
      knnTrainingRequestSearchSize = Nothing,
      knnTrainingRequestDescription = Just "My model"
    }

-- | A representative @method@ sub-object with full @parameters@, drawn from
-- the OpenSearch vector-search train-model example. Only IVF-style methods
-- (FAISS) require training; @parameters@ is deeply nested and
-- engine-specific.
sampleMethodJson :: LBS.ByteString
sampleMethodJson =
  "{\
  \  \"name\": \"ivf\",\
  \  \"engine\": \"faiss\",\
  \  \"parameters\": {\
  \    \"nlist\": 128,\
  \    \"encoder\": {\
  \      \"name\": \"pq\",\
  \      \"parameters\": { \"code_size\": 8 }\
  \    }\
  \  }\
  \}"

-- | A fully-populated train request body. Mirrors the example in the
-- OpenSearch vector-search docs.
sampleFullRequest :: LBS.ByteString
sampleFullRequest =
  "{\
  \  \"training_index\": \"train-index-name\",\
  \  \"training_field\": \"train-field-name\",\
  \  \"dimension\": 16,\
  \  \"max_training_vector_count\": 1200,\
  \  \"search_size\": 100,\
  \  \"description\": \"My model\",\
  \  \"space_type\": \"l2\",\
  \  \"method\": {\
  \    \"name\": \"ivf\",\
  \    \"engine\": \"faiss\",\
  \    \"parameters\": {\
  \      \"nlist\": 128,\
  \      \"encoder\": {\
  \        \"name\": \"pq\",\
  \        \"parameters\": { \"code_size\": 8 }\
  \      }\
  \    }\
  \  }\
  \}"

-- | A minimal request with only the four required fields. Used to verify
-- @omitNulls@ does not leak @null@ for the optional fields.
sampleMinimalRequest :: LBS.ByteString
sampleMinimalRequest =
  "{\
  \  \"training_index\": \"train-index-name\",\
  \  \"training_field\": \"train-field-name\",\
  \  \"dimension\": 4,\
  \  \"method\": {\
  \    \"name\": \"ivf\",\
  \    \"engine\": \"faiss\"\
  \  }\
  \}"

-- | The train endpoint's response is a bare object echoing the @model_id@.
sampleResponse :: LBS.ByteString
sampleResponse = "{ \"model_id\": \"dcdwscddscsad\" }"

spec :: Spec
spec = describe "k-NN Train Model API" $ do
  describe "KnnTrainingMethod JSON" $ do
    it "decodes a method with full parameters" $ do
      let Just decoded = decode sampleMethodJson :: Maybe KnnTrainingMethod
      knnTrainingMethodName decoded `shouldBe` "ivf"
      knnTrainingMethodEngine decoded `shouldBe` KnnEngineFaiss
      knnTrainingMethodSpaceType decoded `shouldBe` Nothing
      knnTrainingMethodParameters decoded `shouldSatisfy` isJust

    it "decodes a method with space_type at the method level" $ do
      let Just decoded =
            decode
              "{ \"name\": \"ivf\", \"engine\": \"faiss\", \"space_type\": \"innerproduct\" }" ::
              Maybe KnnTrainingMethod
      knnTrainingMethodSpaceType decoded `shouldBe` Just "innerproduct"

    it "requires name and engine" $ do
      let decoded = decode "{ \"name\": \"ivf\" }" :: Maybe KnnTrainingMethod
      decoded `shouldBe` Nothing

    it "rejects a top-level array" $ do
      let decoded = decode "[]" :: Maybe KnnTrainingMethod
      decoded `shouldBe` Nothing

    it "round-trips a full method through ToJSON/FromJSON" $ do
      let Just decoded = decode sampleMethodJson :: Maybe KnnTrainingMethod
      (decode . encode) decoded `shouldBe` Just decoded

    it "ToJSON omits Nothing method fields (no null leakage)" $ do
      let method =
            KnnTrainingMethod
              { knnTrainingMethodName = "ivf",
                knnTrainingMethodEngine = KnnEngineFaiss,
                knnTrainingMethodSpaceType = Nothing,
                knnTrainingMethodParameters = Nothing
              }
          encoded = LBS.toStrict (encode method)
      encoded `shouldSatisfy` not . BS.isInfixOf "\"space_type\""
      encoded `shouldSatisfy` not . BS.isInfixOf "\"parameters\""
      encoded `shouldSatisfy` BS.isInfixOf "\"name\":\"ivf\""
      encoded `shouldSatisfy` BS.isInfixOf "\"engine\":\"faiss\""

    it "ToJSON emits method parameters when present" $ do
      let Just decoded = decode sampleMethodJson :: Maybe KnnTrainingMethod
          encoded = LBS.toStrict (encode decoded)
      encoded `shouldSatisfy` BS.isInfixOf "\"parameters\""

  describe "KnnTrainingRequest JSON" $ do
    it "decodes a fully-populated request" $ do
      let Just decoded = decode sampleFullRequest :: Maybe KnnTrainingRequest
      unIndexName (knnTrainingRequestIndex decoded) `shouldBe` "train-index-name"
      unFieldName (knnTrainingRequestField decoded) `shouldBe` "train-field-name"
      knnTrainingRequestDimension decoded `shouldBe` 16
      knnTrainingRequestSpaceType decoded `shouldBe` Just "l2"
      knnTrainingRequestMaxTrainingVectorCount decoded `shouldBe` Just 1200
      knnTrainingRequestSearchSize decoded `shouldBe` Just 100
      knnTrainingRequestDescription decoded `shouldBe` Just "My model"
      (knnTrainingMethodName . knnTrainingRequestMethod) decoded `shouldBe` "ivf"

    it "decodes a minimal (required-only) request" $ do
      let Just decoded = decode sampleMinimalRequest :: Maybe KnnTrainingRequest
      knnTrainingRequestDimension decoded `shouldBe` 4
      knnTrainingRequestSpaceType decoded `shouldBe` Nothing
      knnTrainingRequestMaxTrainingVectorCount decoded `shouldBe` Nothing
      knnTrainingRequestSearchSize decoded `shouldBe` Nothing
      knnTrainingRequestDescription decoded `shouldBe` Nothing

    it "requires the four required fields" $ do
      decode "{ \"training_index\": \"x\", \"training_field\": \"y\", \"dimension\": 4 }"
        `shouldBe` (Nothing :: Maybe KnnTrainingRequest)

    it "tolerates unknown fields (forward compatibility)" $ do
      let Just decoded =
            decode
              "{\
              \  \"training_index\": \"ix\",\
              \  \"training_field\": \"fl\",\
              \  \"dimension\": 2,\
              \  \"method\": { \"name\": \"ivf\", \"engine\": \"faiss\" },\
              \  \"future_field\": 42\
              \}" ::
              Maybe KnnTrainingRequest
      unIndexName (knnTrainingRequestIndex decoded) `shouldBe` "ix"

    it "rejects a top-level array" $ do
      let decoded = decode "[]" :: Maybe KnnTrainingRequest
      decoded `shouldBe` Nothing

    it "rejects malformed JSON" $ do
      let decoded = decode "{ not json" :: Maybe KnnTrainingRequest
      decoded `shouldBe` Nothing

    it "round-trips a full request through ToJSON/FromJSON" $ do
      let Just decoded = decode sampleFullRequest :: Maybe KnnTrainingRequest
      (decode . encode) decoded `shouldBe` Just decoded

    it "round-trips a minimal request through ToJSON/FromJSON" $ do
      let Just decoded = decode sampleMinimalRequest :: Maybe KnnTrainingRequest
      (decode . encode) decoded `shouldBe` Just decoded

    it "ToJSON omits Nothing request fields (no null leakage)" $ do
      let Just decoded = decode sampleMinimalRequest :: Maybe KnnTrainingRequest
          encoded = LBS.toStrict (encode decoded)
      encoded `shouldSatisfy` not . BS.isInfixOf "\"space_type\""
      encoded `shouldSatisfy` not . BS.isInfixOf "\"max_training_vector_count\""
      encoded `shouldSatisfy` not . BS.isInfixOf "\"search_size\""
      encoded `shouldSatisfy` not . BS.isInfixOf "\"description\""

    it "ToJSON serializes training_index and training_field as bare strings (not wrapped objects)" $ do
      let Just decoded = decode sampleMinimalRequest :: Maybe KnnTrainingRequest
          encoded = LBS.toStrict (encode decoded)
      encoded `shouldSatisfy` BS.isInfixOf "\"training_index\":\"train-index-name\""
      encoded `shouldSatisfy` BS.isInfixOf "\"training_field\":\"train-field-name\""

    it "ToJSON emits optional fields when present" $ do
      let Just decoded = decode sampleFullRequest :: Maybe KnnTrainingRequest
          encoded = LBS.toStrict (encode decoded)
      encoded `shouldSatisfy` BS.isInfixOf "\"space_type\":\"l2\""
      encoded `shouldSatisfy` BS.isInfixOf "\"max_training_vector_count\":1200"
      encoded `shouldSatisfy` BS.isInfixOf "\"search_size\":100"

  describe "KnnTrainResponse JSON" $ do
    it "decodes the documented response body" $ do
      let Just decoded = decode sampleResponse :: Maybe KnnTrainResponse
      knnTrainResponseModelId decoded `shouldBe` "dcdwscddscsad"

    it "requires the model_id field" $ do
      let decoded = decode "{}" :: Maybe KnnTrainResponse
      decoded `shouldBe` Nothing

    it "rejects a top-level array" $ do
      let decoded = decode "[]" :: Maybe KnnTrainResponse
      decoded `shouldBe` Nothing

    it "round-trips through ToJSON/FromJSON" $ do
      let Just decoded = decode sampleResponse :: Maybe KnnTrainResponse
      (decode . encode) decoded `shouldBe` Just decoded

  describe "trainKnnModel endpoint shape" $ do
    -- Pure checks against the BHRequest shape; no live backend needed.
    it "POSTs to /_plugins/_knn/models/{model_id}/_train for the given ModelId" $ do
      let req = OS3Requests.trainKnnModel (ModelId "test-model") sampleReq
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_knn", "models", "test-model", "_train"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "uses the model id as a single opaque path segment" $ do
      let req = OS3Requests.trainKnnModel (ModelId "faiss-ivf-pq_42") sampleReq
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_knn", "models", "faiss-ivf-pq_42", "_train"]

    it "uses a distinct id in the path when given a different ModelId" $ do
      let req = OS3Requests.trainKnnModel (ModelId "other-model") sampleReq
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_knn", "models", "other-model", "_train"]

    it "uses the POST method" $ do
      let req = OS3Requests.trainKnnModel (ModelId "test-model") sampleReq
      bhRequestMethod req `shouldBe` "POST"

    it "attaches a JSON body (POST semantics)" $ do
      let req = OS3Requests.trainKnnModel (ModelId "test-model") sampleReq
      bhRequestBody req `shouldSatisfy` isJust

    it "embeds training_index and method in the request body" $ do
      let req = OS3Requests.trainKnnModel (ModelId "test-model") sampleReq
          Just bodyBytes = bhRequestBody req
      case decode bodyBytes of
        Just (Object obj) -> do
          KM.member "training_index" obj `shouldBe` True
          KM.member "training_field" obj `shouldBe` True
          KM.member "dimension" obj `shouldBe` True
          KM.member "method" obj `shouldBe` True
        _ -> expectationFailure "body did not decode to Object"

    it "does not leak null for the unset optional fields" $ do
      let req = OS3Requests.trainKnnModel (ModelId "test-model") sampleReq
          Just bodyBytes = bhRequestBody req
          encoded = LBS.toStrict bodyBytes
      encoded `shouldSatisfy` not . BS.isInfixOf "\"space_type\":null"
      encoded `shouldSatisfy` not . BS.isInfixOf "\"max_training_vector_count\":null"
      encoded `shouldSatisfy` not . BS.isInfixOf "\"search_size\":null"

  describe "trainKnnModelWith endpoint shape" $ do
    it "trainKnnModel is equivalent to trainKnnModelWith Nothing" $ do
      let base = OS3Requests.trainKnnModel (ModelId "test-model") sampleReq
          withNothing = OS3Requests.trainKnnModelWith Nothing (ModelId "test-model") sampleReq
      getRawEndpoint (bhRequestEndpoint base)
        `shouldBe` getRawEndpoint (bhRequestEndpoint withNothing)
      getRawEndpointQueries (bhRequestEndpoint base)
        `shouldBe` getRawEndpointQueries (bhRequestEndpoint withNothing)
      bhRequestMethod base `shouldBe` bhRequestMethod withNothing

    it "trainKnnModelWith Nothing produces no query parameters" $ do
      let req = OS3Requests.trainKnnModelWith Nothing (ModelId "test-model") sampleReq
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_knn", "models", "test-model", "_train"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "trainKnnModelWith (Just node) attaches ?preference=<node_id>" $ do
      let req = OS3Requests.trainKnnModelWith (Just (KnnNodeId "node-abc")) (ModelId "test-model") sampleReq
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_knn", "models", "test-model", "_train"]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("preference", Just "node-abc")]

    it "trainKnnModelWith preserves the path for an opaque node id" $ do
      let req = OS3Requests.trainKnnModelWith (Just (KnnNodeId "ZcRWj1BLTEq9OMxK3wEuwA")) (ModelId "faiss-ivf-pq_42") sampleReq
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_knn", "models", "faiss-ivf-pq_42", "_train"]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("preference", Just "ZcRWj1BLTEq9OMxK3wEuwA")]

    it "trainKnnModelWith uses the POST method and attaches a JSON body" $ do
      let req = OS3Requests.trainKnnModelWith (Just (KnnNodeId "node-1")) (ModelId "test-model") sampleReq
      bhRequestMethod req `shouldBe` "POST"
      bhRequestBody req `shouldSatisfy` isJust