bloodhound-1.0.0.0: tests/Test/NeuralSparseQuerySpec.hs
{-# LANGUAGE OverloadedStrings #-}
-- | Pure-JSON tests for the OpenSearch @neural_sparse@ query clause.
--
-- These tests do not require a running OpenSearch instance; they verify
-- that 'NeuralSparseQuery' (and the 'QueryNeuralSparseQuery' arm of
-- 'Query') serialize to the JSON shape documented at
-- <https://docs.opensearch.org/latest/query-dsl/specialized/neural-sparse/>
-- and round-trip through 'ToJSON' \/ 'FromJSON'.
module Test.NeuralSparseQuerySpec (spec) where
import Data.Aeson
import Data.Aeson.Key (Key)
import Data.Aeson.Key qualified as AK
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy qualified as LBS
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as M
import Data.Maybe (fromJust)
import Data.Text (Text)
import Database.Bloodhound.Types
( FieldName (..),
NeuralSparseInput (..),
NeuralSparseMethodParameters (..),
NeuralSparseModelSource (..),
NeuralSparseQuery (..),
Query (..),
mkNeuralSparseQueryText,
mkNeuralSparseQueryTextAnalyzer,
mkNeuralSparseQueryTokens,
mkNeuralSparseQueryTokensAnalyzer,
)
import Test.Hspec (Spec, describe, it, shouldBe, shouldNotSatisfy, shouldSatisfy)
textField :: FieldName
textField = FieldName "passage_embedding"
sparseVectorField :: FieldName
sparseVectorField = FieldName "sparse_embedding"
modelId :: Text
modelId = "aP2Q8ooBpBj3wT4HVS8a"
analyzer :: Text
analyzer = "bert-uncased"
sampleTokens :: Map Text Double
sampleTokens =
M.fromList
[ ("hello", 3.3210192),
("world", 2.6087382)
]
-- | Walk into a nested dict path, returning the inner Value if reachable.
walk :: [Key] -> Value -> Maybe Value
walk [] v = Just v
walk (k : ks) (Object obj) = case KM.lookup k obj of
Just v -> walk ks v
Nothing -> Nothing
walk _ _ = Nothing
-- | True iff the encoded NeuralSparseQuery produces the OS-correct shape
-- @{"neural_sparse": {<field>: {...}}}@ with the field name as a dict key.
hasNeuralSparseNestedUnderField :: FieldName -> Value -> Bool
hasNeuralSparseNestedUnderField (FieldName f) (Object obj) =
case KM.lookup "neural_sparse" obj of
Just (Object neuralObj) -> KM.member (AK.fromText f) neuralObj
_ -> False
hasNeuralSparseNestedUnderField _ _ = False
-- | Bytestring variant for direct use with @\`shouldSatisfy\`@ on @encode@ output.
hasNeuralSparseNestedUnderFieldBS :: FieldName -> LBS.ByteString -> Bool
hasNeuralSparseNestedUnderFieldBS field bs =
case decode bs of
Just v -> hasNeuralSparseNestedUnderField field v
Nothing -> False
-- | Lookup the inner clause body for a given field name.
clauseBody :: FieldName -> Value -> Maybe Value
clauseBody (FieldName f) v =
walk ["neural_sparse", AK.fromText f] v
-- | True iff the encoded value is an Object containing the given key.
hasKey :: Key -> LBS.ByteString -> Bool
hasKey k bs = case decode bs of
Just (Object obj) -> k `KM.member` obj
_ -> False
spec :: Spec
spec = describe "Neural Sparse query clause (OpenSearch)" $ do
describe "NeuralSparseQuery body shape (text input, model_id)" $ do
it "serializes as neural_sparse.<field> with the field name as the dict key" $ do
let clause = mkNeuralSparseQueryText textField "Hi world" modelId
let json = encode clause
json `shouldSatisfy` hasKey "neural_sparse"
json `shouldSatisfy` hasNeuralSparseNestedUnderFieldBS textField
it "emits query_text for text input" $ do
let clause = mkNeuralSparseQueryText textField "Hi world" modelId
let Just (Object body) = clauseBody textField (fromJust (decode (encode clause)))
KM.lookup "query_text" body `shouldBe` Just (String "Hi world")
body `shouldNotSatisfy` KM.member "query_tokens"
it "emits model_id when set" $ do
let clause = mkNeuralSparseQueryText textField "Hi world" modelId
let Just (Object body) = clauseBody textField (fromJust (decode (encode clause)))
KM.lookup "model_id" body `shouldBe` Just (String modelId)
body `shouldNotSatisfy` KM.member "analyzer"
it "omits method_parameters when not set" $ do
let clause = mkNeuralSparseQueryText textField "Hi world" modelId
let Just (Object body) = clauseBody textField (fromJust (decode (encode clause)))
body `shouldNotSatisfy` KM.member "method_parameters"
it "never emits the deprecated max_token_score field" $ do
let clause = mkNeuralSparseQueryText textField "Hi world" modelId
let Just (Object body) = clauseBody textField (fromJust (decode (encode clause)))
body `shouldNotSatisfy` KM.member "max_token_score"
describe "NeuralSparseQuery body shape (text input, analyzer)" $ do
it "emits analyzer when set" $ do
let clause = mkNeuralSparseQueryTextAnalyzer textField "Hi world" analyzer
let Just (Object body) = clauseBody textField (fromJust (decode (encode clause)))
KM.lookup "analyzer" body `shouldBe` Just (String analyzer)
body `shouldNotSatisfy` KM.member "model_id"
describe "NeuralSparseQuery body shape (tokens input)" $ do
it "emits query_tokens (not query_text) for raw token input" $ do
let clause = mkNeuralSparseQueryTokens textField sampleTokens modelId
let Just (Object body) = clauseBody textField (fromJust (decode (encode clause)))
case KM.lookup "query_tokens" body of
Just (Object _) -> pure ()
other -> fail $ "expected query_tokens object, got " ++ show other
body `shouldNotSatisfy` KM.member "query_text"
it "serializes the token weights as a string-keyed map" $ do
let clause = mkNeuralSparseQueryTokens textField sampleTokens modelId
let Just (Object body) = clauseBody textField (fromJust (decode (encode clause)))
case KM.lookup "query_tokens" body of
Just (Object tokensObj) -> do
KM.lookup "hello" tokensObj `shouldBe` Just (toJSON (3.3210192 :: Double))
KM.lookup "world" tokensObj `shouldBe` Just (toJSON (2.6087382 :: Double))
other -> fail $ "expected query_tokens object, got " ++ show other
it "supports analyzer source with token input" $ do
let clause = mkNeuralSparseQueryTokensAnalyzer textField sampleTokens analyzer
let Just (Object body) = clauseBody textField (fromJust (decode (encode clause)))
KM.lookup "analyzer" body `shouldBe` Just (String analyzer)
body `shouldNotSatisfy` KM.member "model_id"
describe "NeuralSparseQuery body shape (default analyzer)" $ do
it "omits both model_id and analyzer when no source is set" $ do
let clause =
(mkNeuralSparseQueryText textField "Hi world" modelId)
{ neuralSparseModelSource = Nothing
}
let Just (Object body) = clauseBody textField (fromJust (decode (encode clause)))
body `shouldNotSatisfy` KM.member "model_id"
body `shouldNotSatisfy` KM.member "analyzer"
describe "NeuralSparseQuery method_parameters" $ do
it "emits method_parameters when set on sparse_vector fields" $ do
let clause =
(mkNeuralSparseQueryText sparseVectorField "Hi world" modelId)
{ neuralSparseMethodParameters =
Just
NeuralSparseMethodParameters
{ neuralSparseMethodParametersTopN = Just 10,
neuralSparseMethodParametersHeapFactor = Just 1.0,
neuralSparseMethodParametersK = Just 10
}
}
let Just (Object body) = clauseBody sparseVectorField (fromJust (decode (encode clause)))
case KM.lookup "method_parameters" body of
Just (Object mp) -> do
KM.lookup "top_n" mp `shouldBe` Just (toJSON (10 :: Int))
KM.lookup "heap_factor" mp `shouldBe` Just (toJSON (1.0 :: Double))
KM.lookup "k" mp `shouldBe` Just (toJSON (10 :: Int))
other -> fail $ "expected method_parameters object, got " ++ show other
it "omits null sub-fields of method_parameters" $ do
let clause =
(mkNeuralSparseQueryText sparseVectorField "Hi world" modelId)
{ neuralSparseMethodParameters =
Just
NeuralSparseMethodParameters
{ neuralSparseMethodParametersTopN = Just 10,
neuralSparseMethodParametersHeapFactor = Nothing,
neuralSparseMethodParametersK = Nothing
}
}
let Just (Object body) = clauseBody sparseVectorField (fromJust (decode (encode clause)))
case KM.lookup "method_parameters" body of
Just (Object mp) -> do
KM.lookup "top_n" mp `shouldBe` Just (toJSON (10 :: Int))
mp `shouldNotSatisfy` KM.member "heap_factor"
mp `shouldNotSatisfy` KM.member "k"
other -> fail $ "expected method_parameters object, got " ++ show other
describe "NeuralSparseQuery round-trip" $ do
it "round-trips a text + model_id query through ToJSON/FromJSON" $ do
let clause = mkNeuralSparseQueryText textField "Hi world" modelId
decode (encode clause) `shouldBe` Just clause
it "round-trips a text + analyzer query through ToJSON/FromJSON" $ do
let clause = mkNeuralSparseQueryTextAnalyzer textField "Hi world" analyzer
decode (encode clause) `shouldBe` Just clause
it "round-trips a text query with no model source (default analyzer)" $ do
let clause =
(mkNeuralSparseQueryText textField "Hi world" modelId)
{ neuralSparseModelSource = Nothing
}
decode (encode clause) `shouldBe` Just clause
it "round-trips a tokens + model_id query through ToJSON/FromJSON" $ do
let clause = mkNeuralSparseQueryTokens textField sampleTokens modelId
decode (encode clause) `shouldBe` Just clause
it "round-trips a query with method_parameters" $ do
let clause =
(mkNeuralSparseQueryText sparseVectorField "Hi world" modelId)
{ neuralSparseMethodParameters =
Just
NeuralSparseMethodParameters
{ neuralSparseMethodParametersTopN = Just 10,
neuralSparseMethodParametersHeapFactor = Just 1.0,
neuralSparseMethodParametersK = Just 10
}
}
decode (encode clause) `shouldBe` Just clause
it "round-trips a query with partially-populated method_parameters" $ do
let clause =
(mkNeuralSparseQueryText sparseVectorField "Hi world" modelId)
{ neuralSparseMethodParameters =
Just
NeuralSparseMethodParameters
{ neuralSparseMethodParametersTopN = Just 5,
neuralSparseMethodParametersHeapFactor = Nothing,
neuralSparseMethodParametersK = Just 7
}
}
decode (encode clause) `shouldBe` Just clause
describe "NeuralSparseInput and NeuralSparseModelSource" $ do
it "NeuralSparseTextInput round-trips" $ do
let input = NeuralSparseTextInput "hello"
decode (encode input) `shouldBe` Just input
it "NeuralSparseTokensInput round-trips" $ do
let input = NeuralSparseTokensInput sampleTokens
decode (encode input) `shouldBe` Just input
it "NeuralSparseByModelId round-trips" $ do
let src = NeuralSparseByModelId modelId
decode (encode src) `shouldBe` Just src
it "NeuralSparseByAnalyzer round-trips" $ do
let src = NeuralSparseByAnalyzer analyzer
decode (encode src) `shouldBe` Just src
it "NeuralSparseMethodParameters round-trips with all fields set" $ do
let params =
NeuralSparseMethodParameters
{ neuralSparseMethodParametersTopN = Just 10,
neuralSparseMethodParametersHeapFactor = Just 1.0,
neuralSparseMethodParametersK = Just 5
}
decode (encode params) `shouldBe` Just params
it "rejects NeuralSparseInput with neither query_text nor query_tokens" $ do
let bad = object ["something_else" .= String "x"]
(decode (encode bad) :: Maybe NeuralSparseInput) `shouldBe` Nothing
it "rejects NeuralSparseModelSource with neither model_id nor analyzer" $ do
let bad = object ["unrelated" .= Number 1]
(decode (encode bad) :: Maybe NeuralSparseModelSource) `shouldBe` Nothing
describe "Query sum type integration" $ do
it "QueryNeuralSparseQuery wraps the clause under the \"neural_sparse\" key" $ do
let inner = mkNeuralSparseQueryText textField "Hi world" modelId
let wrapped = QueryNeuralSparseQuery inner
let json = encode wrapped
json `shouldSatisfy` \bs -> case decode bs of
Just (Object obj) -> KM.member "neural_sparse" obj
_ -> False
-- Regression guard for the double-wrap bug: the Query wrapper must NOT
-- re-wrap the leaf (which already self-wraps under "neural_sparse").
-- The wire shape must be exactly {"neural_sparse": {<field>: {...}}},
-- not {"neural_sparse": {"neural_sparse": {<field>: {...}}}}.
it "QueryNeuralSparseQuery does NOT double-wrap the leaf neural_sparse key" $ do
let inner = mkNeuralSparseQueryText textField "Hi world" modelId
let json = encode (QueryNeuralSparseQuery inner)
let Just (Object outer) = decode json
Just (Object neuralSparseObj) = KM.lookup "neural_sparse" outer
neuralSparseObj `shouldNotSatisfy` KM.member "neural_sparse"
neuralSparseObj `shouldSatisfy` KM.member (AK.fromText "passage_embedding")
-- Walk-into-body assertion: the body shape under obj.neural_sparse.<field>
-- must match the OS-correct clause body. This is the test that would
-- have caught the double-wrap bug.
it "QueryNeuralSparseQuery produces OS-correct body shape under neural_sparse.<field>" $ do
let inner = mkNeuralSparseQueryText textField "Hi world" modelId
let Just (Object body) = walk ["neural_sparse", AK.fromText "passage_embedding"] (fromJust (decode (encode (QueryNeuralSparseQuery inner))))
KM.lookup "query_text" body `shouldBe` Just (String "Hi world")
KM.lookup "model_id" body `shouldBe` Just (String modelId)
it "QueryNeuralSparseQuery round-trips through the parent Query sum type" $ do
let inner = mkNeuralSparseQueryText textField "Hi world" modelId
let wrapped = QueryNeuralSparseQuery inner
decode (encode wrapped) `shouldBe` Just wrapped
it "QueryNeuralSparseQuery round-trips with tokens input" $ do
let inner = mkNeuralSparseQueryTokens textField sampleTokens modelId
let wrapped = QueryNeuralSparseQuery inner
decode (encode wrapped) `shouldBe` Just wrapped
it "QueryNeuralSparseQuery round-trips with method_parameters set" $ do
let inner =
(mkNeuralSparseQueryText sparseVectorField "Hi world" modelId)
{ neuralSparseMethodParameters =
Just
NeuralSparseMethodParameters
{ neuralSparseMethodParametersTopN = Just 10,
neuralSparseMethodParametersHeapFactor = Just 1.0,
neuralSparseMethodParametersK = Just 10
}
}
let wrapped = QueryNeuralSparseQuery inner
decode (encode wrapped) `shouldBe` Just wrapped
-- End-to-end: an OS-shaped wire JSON decodes to the expected Query.
-- Pre-fix, this returned Nothing because the decoder expected the
-- double-wrapped shape; post-fix, it succeeds.
it "decodes a real OS-shaped neural_sparse wire JSON" $ do
let wire = "{\"neural_sparse\":{\"passage_embedding\":{\"query_text\":\"Hi world\",\"model_id\":\"aP2Q8ooBpBj3wT4HVS8a\"}}}"
let expected = QueryNeuralSparseQuery (mkNeuralSparseQueryText textField "Hi world" modelId)
(decode wire :: Maybe Query) `shouldBe` Just expected
it "distinguishes neural_sparse from neural in the parser" $ do
let inner = mkNeuralSparseQueryText textField "Hi world" modelId
let wrapped = QueryNeuralSparseQuery inner
let Just (Object obj) = decode (encode wrapped)
obj `shouldNotSatisfy` KM.member "neural"
obj `shouldSatisfy` KM.member "neural_sparse"
describe "NeuralSparseQuery parser rejects malformed clauses" $ do
it "rejects neural_sparse clause with multiple field keys" $ do
let bad =
object
[ "neural_sparse"
.= object
[ "field_a" .= object ["query_text" .= String "x", "model_id" .= String "m"],
"field_b" .= object ["query_text" .= String "y", "model_id" .= String "m"]
]
]
(decode (encode bad) :: Maybe NeuralSparseQuery) `shouldBe` Nothing
it "rejects neural_sparse clause with no field key" $ do
let bad = object ["neural_sparse" .= object []]
(decode (encode bad) :: Maybe NeuralSparseQuery) `shouldBe` Nothing
it "rejects neural_sparse clause with no input field" $ do
let bad =
object
[ "neural_sparse"
.= object
[ "passage_embedding"
.= object
["model_id" .= String "m"]
]
]
(decode (encode bad) :: Maybe NeuralSparseQuery) `shouldBe` Nothing
it "accepts neural_sparse clause with model_id and analyzer absent (default)" $ do
let ok =
object
[ "neural_sparse"
.= object
[ "passage_embedding"
.= object
["query_text" .= String "Hi world"]
]
]
let expected =
NeuralSparseQuery
{ neuralSparseField = textField,
neuralSparseInput = NeuralSparseTextInput "Hi world",
neuralSparseModelSource = Nothing,
neuralSparseMethodParameters = Nothing
}
(decode (encode ok) :: Maybe NeuralSparseQuery) `shouldBe` Just expected