bloodhound-1.0.0.0: tests/Test/NeuralQuerySpec.hs
{-# LANGUAGE OverloadedStrings #-}
-- | Pure-JSON tests for the OpenSearch @neural@ query clause.
--
-- These tests do not require a running OpenSearch instance; they verify
-- that 'NeuralQuery' (and the 'QueryNeuralQuery' arm of 'Query') serialize
-- to the JSON shape documented at
-- <https://docs.opensearch.org/latest/search-plugins/neural-search/#using-the-neural-query-clause>
-- and round-trip through 'ToJSON' \/ 'FromJSON'.
module Test.NeuralQuerySpec (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.Maybe (fromJust)
import Data.Text (Text)
import Database.Bloodhound.Types
( FieldName (..),
NeuralQuery (..),
NeuralQueryInput (..),
NeuralQueryTermination (..),
Query (..),
mkNeuralQuery,
mkNeuralQueryByMaxDistance,
mkNeuralQueryByMinScore,
)
import Test.Hspec (Spec, describe, it, shouldBe, shouldNotSatisfy, shouldSatisfy)
textField :: FieldName
textField = FieldName "passage_embedding"
imageField :: FieldName
imageField = FieldName "image_embedding"
modelId :: Text
modelId = "aVeifm3sBaaSkm0rKKxQ"
-- | 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 NeuralQuery produces the OS-correct shape
-- @{"neural": {<field>: {...}}}@ with the field name as a dict key.
hasNeuralNestedUnderField :: FieldName -> Value -> Bool
hasNeuralNestedUnderField (FieldName f) (Object obj) =
case KM.lookup "neural" obj of
Just (Object neuralObj) -> KM.member (AK.fromText f) neuralObj
_ -> False
hasNeuralNestedUnderField _ _ = False
-- | Bytestring variant for direct use with @\`shouldSatisfy\`@ on @encode@ output.
hasNeuralNestedUnderFieldBS :: FieldName -> LBS.ByteString -> Bool
hasNeuralNestedUnderFieldBS field bs =
case decode bs of
Just v -> hasNeuralNestedUnderField field v
Nothing -> False
spec :: Spec
spec = describe "Neural query clause (OpenSearch)" $ do
describe "NeuralQuery body shape (text input)" $ do
it "serializes as neural.<field> with the field name as the dict key" $ do
let clause = mkNeuralQuery textField (NeuralTextInput "hello world") modelId 5
let json = encode clause
json `shouldSatisfy` hasKey "neural"
json `shouldSatisfy` hasNeuralNestedUnderFieldBS textField
it "emits query_text (not query_image) for text input" $ do
let clause = mkNeuralQuery textField (NeuralTextInput "hello world") modelId 5
let Just (Object body) = walk ["neural", AK.fromText "passage_embedding"] (fromJust (decode (encode clause)))
KM.lookup "query_text" body `shouldBe` Just (String "hello world")
body `shouldNotSatisfy` KM.member "query_image"
it "omits boost when not set" $ do
let clause = mkNeuralQuery textField (NeuralTextInput "hello world") modelId 5
let Just (Object body) = walk ["neural", AK.fromText "passage_embedding"] (fromJust (decode (encode clause)))
body `shouldNotSatisfy` KM.member "boost"
it "includes boost when set" $ do
let clause = (mkNeuralQuery textField (NeuralTextInput "hello world") modelId 5) {neuralBoost = Just 1.5}
let Just (Object body) = walk ["neural", AK.fromText "passage_embedding"] (fromJust (decode (encode clause)))
KM.lookup "boost" body `shouldBe` Just (toJSON (1.5 :: Double))
describe "NeuralQuery body shape (image input)" $ do
it "emits query_image (not query_text) for image input" $ do
let clause = mkNeuralQuery imageField (NeuralImageInput "iVBORw0KGgo...") modelId 3
let Just (Object body) = walk ["neural", AK.fromText "image_embedding"] (fromJust (decode (encode clause)))
KM.lookup "query_image" body `shouldBe` Just (String "iVBORw0KGgo...")
body `shouldNotSatisfy` KM.member "query_text"
describe "NeuralQuery radial-search variants" $ do
it "max_distance variant emits max_distance and no k in the clause body" $ do
let clause = mkNeuralQueryByMaxDistance textField (NeuralTextInput "hello") modelId 1.5
let Just (Object body) = walk ["neural", AK.fromText "passage_embedding"] (fromJust (decode (encode clause)))
KM.lookup "max_distance" body `shouldBe` Just (toJSON (1.5 :: Double))
body `shouldNotSatisfy` KM.member "k"
body `shouldNotSatisfy` KM.member "min_score"
it "min_score variant emits min_score and no k in the clause body" $ do
let clause = mkNeuralQueryByMinScore textField (NeuralTextInput "hello") modelId 0.8
let Just (Object body) = walk ["neural", AK.fromText "passage_embedding"] (fromJust (decode (encode clause)))
KM.lookup "min_score" body `shouldBe` Just (toJSON (0.8 :: Double))
body `shouldNotSatisfy` KM.member "k"
body `shouldNotSatisfy` KM.member "max_distance"
it "k variant still emits k (regression)" $ do
let clause = mkNeuralQuery textField (NeuralTextInput "hello") modelId 5
let Just (Object body) = walk ["neural", AK.fromText "passage_embedding"] (fromJust (decode (encode clause)))
KM.lookup "k" body `shouldBe` Just (toJSON (5 :: Int))
body `shouldNotSatisfy` KM.member "max_distance"
body `shouldNotSatisfy` KM.member "min_score"
describe "NeuralQuery round-trip" $ do
it "round-trips a minimal text+k query through ToJSON/FromJSON" $ do
let clause = mkNeuralQuery textField (NeuralTextInput "hello") modelId 5
decode (encode clause) `shouldBe` Just clause
it "round-trips an image+k query through ToJSON/FromJSON" $ do
let clause = mkNeuralQuery imageField (NeuralImageInput "iVBORw0KGgo...") modelId 3
decode (encode clause) `shouldBe` Just clause
it "round-trips a fully-populated text+k query (with boost)" $ do
let clause =
(mkNeuralQuery textField (NeuralTextInput "hello") modelId 5)
{ neuralBoost = Just 1.5
}
decode (encode clause) `shouldBe` Just clause
it "round-trips a radial max_distance query" $ do
let clause = mkNeuralQueryByMaxDistance textField (NeuralTextInput "hello") modelId 1.5
decode (encode clause) `shouldBe` Just clause
it "round-trips a radial min_score query" $ do
let clause = mkNeuralQueryByMinScore textField (NeuralTextInput "hello") modelId 0.8
decode (encode clause) `shouldBe` Just clause
describe "NeuralQueryInput and NeuralQueryTermination" $ do
it "NeuralTextInput round-trips" $ do
let input = NeuralTextInput "hello"
decode (encode input) `shouldBe` Just input
it "NeuralImageInput round-trips" $ do
let input = NeuralImageInput "iVBORw0KGgo..."
decode (encode input) `shouldBe` Just input
it "NeuralTerminationByK round-trips" $ do
let term = NeuralTerminationByK 5
decode (encode term) `shouldBe` Just term
it "NeuralTerminationByMaxDistance round-trips" $ do
let term = NeuralTerminationByMaxDistance 1.5
decode (encode term) `shouldBe` Just term
it "NeuralTerminationByMinScore round-trips" $ do
let term = NeuralTerminationByMinScore 0.8
decode (encode term) `shouldBe` Just term
it "rejects NeuralQueryInput with neither query_text nor query_image" $ do
let bad = object ["something_else" .= String "x"]
(decode (encode bad) :: Maybe NeuralQueryInput) `shouldBe` Nothing
it "rejects NeuralQueryTermination with no k/max_distance/min_score" $ do
let bad = object ["unrelated" .= Number 1]
(decode (encode bad) :: Maybe NeuralQueryTermination) `shouldBe` Nothing
describe "Query sum type integration" $ do
it "QueryNeuralQuery wraps the clause under the \"neural\" key" $ do
let inner = mkNeuralQuery textField (NeuralTextInput "hello") modelId 5
let wrapped = QueryNeuralQuery inner
let json = encode wrapped
json `shouldSatisfy` \bs -> case decode bs of
Just (Object obj) -> KM.member "neural" obj
_ -> False
-- Regression guard for the double-wrap bug: the Query wrapper must NOT
-- re-wrap the leaf (which already self-wraps under "neural"). The
-- wire shape must be exactly {"neural": {<field>: {...}}}, not
-- {"neural": {"neural": {<field>: {...}}}}.
it "QueryNeuralQuery does NOT double-wrap the leaf neural key" $ do
let inner = mkNeuralQuery textField (NeuralTextInput "hello") modelId 5
let json = encode (QueryNeuralQuery inner)
let Just (Object outer) = decode json
Just (Object neuralObj) = KM.lookup "neural" outer
-- The inner object must be field-keyed (NOT re-keyed under "neural").
neuralObj `shouldNotSatisfy` KM.member "neural"
neuralObj `shouldSatisfy` KM.member (AK.fromText "passage_embedding")
-- Walk-into-body assertion: the body shape under obj.neural.<field>
-- must match the OS-correct clause body. This is the test that would
-- have caught the double-wrap bug.
it "QueryNeuralQuery produces OS-correct body shape under neural.<field>" $ do
let inner = mkNeuralQuery textField (NeuralTextInput "hello") modelId 5
let Just (Object body) = walk ["neural", AK.fromText "passage_embedding"] (fromJust (decode (encode (QueryNeuralQuery inner))))
KM.lookup "query_text" body `shouldBe` Just (String "hello")
KM.lookup "model_id" body `shouldBe` Just (String modelId)
KM.lookup "k" body `shouldBe` Just (toJSON (5 :: Int))
it "QueryNeuralQuery preserves boost in the body shape" $ do
let inner = (mkNeuralQuery textField (NeuralTextInput "hello") modelId 5) {neuralBoost = Just 2.0}
let Just (Object body) = walk ["neural", AK.fromText "passage_embedding"] (fromJust (decode (encode (QueryNeuralQuery inner))))
KM.lookup "boost" body `shouldBe` Just (toJSON (2.0 :: Double))
it "QueryNeuralQuery round-trips through the parent Query sum type" $ do
let inner = mkNeuralQuery textField (NeuralTextInput "hello") modelId 5
let wrapped = QueryNeuralQuery inner
decode (encode wrapped) `shouldBe` Just wrapped
it "QueryNeuralQuery round-trips with boost set" $ do
let inner =
(mkNeuralQuery textField (NeuralTextInput "hello") modelId 5)
{ neuralBoost = Just 2.0
}
let wrapped = QueryNeuralQuery 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 wire JSON" $ do
let wire = "{\"neural\":{\"passage_embedding\":{\"query_text\":\"hello\",\"model_id\":\"aVeifm3sBaaSkm0rKKxQ\",\"k\":5}}}"
let expected = QueryNeuralQuery (mkNeuralQuery textField (NeuralTextInput "hello") modelId 5)
(decode wire :: Maybe Query) `shouldBe` Just expected
it "distinguishes neural from neural_sparse in the parser" $ do
let inner = mkNeuralQuery textField (NeuralTextInput "hello") modelId 5
let wrapped = QueryNeuralQuery inner
let Just (Object obj) = decode (encode wrapped)
obj `shouldSatisfy` KM.member "neural"
obj `shouldNotSatisfy` KM.member "neural_sparse"
it "rejects neural clause with multiple field keys" $ do
let bad =
object
[ "neural"
.= object
[ "field_a" .= object ["query_text" .= String "x", "model_id" .= String "m", "k" .= Number 1],
"field_b" .= object ["query_text" .= String "y", "model_id" .= String "m", "k" .= Number 1]
]
]
(decode (encode bad) :: Maybe NeuralQuery) `shouldBe` Nothing
it "rejects neural clause with no field key" $ do
let bad = object ["neural" .= object []]
(decode (encode bad) :: Maybe NeuralQuery) `shouldBe` Nothing
it "rejects neural clause with no termination field" $ do
let bad =
object
[ "neural"
.= object
[ "passage_embedding"
.= object
[ "query_text" .= String "x",
"model_id" .= String "m"
]
]
]
(decode (encode bad) :: Maybe NeuralQuery) `shouldBe` Nothing
-- | 'hasKey' helper mirroring 'KnnOs3Spec' style: takes a 'Key' and a raw
-- encoded 'LBS.ByteString', decoding internally.
hasKey :: Key -> LBS.ByteString -> Bool
hasKey k bs = case decode bs of
Just (Object obj) -> k `KM.member` obj
_ -> False