packages feed

bloodhound-1.0.0.0: tests/Test/RankEvalSpec.hs

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

-- | Tests for the @POST /_rank_eval@ and @POST \/{index}\/_rank_eval@
-- endpoints.
--
-- The first sections are pure unit tests of the JSON instances and
-- 'RankEvalOptions' rendering — no live backend required. The last
-- section is live integration coverage under 'withTestEnv', which is
-- only meaningful when an Elasticsearch\/OpenSearch backend is
-- reachable on 'ES_TEST_SERVER' (default @http://localhost:9200@).
module Test.RankEvalSpec (spec) where

import Data.Aeson
import Data.Aeson.KeyMap qualified as X
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.List (sort)
import Data.Map.Strict qualified as M
import Database.Bloodhound.Common.Requests qualified as Common
import TestsUtils.Common
import TestsUtils.Import

-- ---------------------------------------------------------------------------
-- - URI param rendering
-- ---------------------------------------------------------------------------

normalize :: [(Text, Maybe Text)] -> [(Text, Maybe Text)]
normalize = sort

-- ---------------------------------------------------------------------------
-- - Fixtures
-- ---------------------------------------------------------------------------

-- | Response fixture modelled on the canonical ES example at
-- <https://www.elastic.co/guide/en/elasticsearch/reference/current/search-rank-eval.html>.
sampleRankEvalResponse :: LBS.ByteString
sampleRankEvalResponse =
  "{\
  \  \"rank_eval_score\": 0.4347826086956522,\
  \  \"_shards\": {\"total\": 1, \"successful\": 1, \"skipped\": 0, \"failed\": 0},\
  \  \"details\": {\
  \    \"coffee_beans\": {\
  \      \"metric_score\": 1.0,\
  \      \"unrated_docs\": [\
  \        {\"_index\": \"my-index-000001\", \"_id\": \"dQfYfIkEcA24zrCA0p3U\"}\
  \      ],\
  \      \"hits\": [\
  \        {\"_index\": \"my-index-000001\", \"_id\": \"FojvYIkEcA24zrCA0p3W\", \"_score\": 0.5753642, \"rating\": 1}\
  \      ],\
  \      \"metric_details\": {\
  \        \"precision\": {\
  \          \"relevant_docs_retrieved\": 1,\
  \          \"docs_retrieved\": 1\
  \        }\
  \      }\
  \    }\
  \  },\
  \  \"failures\": {}\
  \}"

-- | OpenSearch-shaped response. Two differences from ES:
--
--   * Top-level score is keyed @metric_score@ (not @rank_eval_score@).
--   * No @_shards@ block.
--   * Each hit's @_index\/_id\/_score@ is nested under a @hit@ key.
--
-- The 'FromJSON RankEvalResponse' / 'FromJSON RankEvalHit' instances
-- accept either shape; this fixture pins the OpenSearch side so a
-- future refactor that drops the tolerant parse is caught.
sampleOpenSearchRankEvalResponse :: LBS.ByteString
sampleOpenSearchRankEvalResponse =
  "{\
  \  \"metric_score\": 1.0,\
  \  \"details\": {\
  \    \"coffee_beans\": {\
  \      \"metric_score\": 1.0,\
  \      \"unrated_docs\": [],\
  \      \"hits\": [\
  \        {\"hit\": {\"_index\": \"my-index-000001\", \"_id\": \"FojvYIkEcA24zrCA0p3W\", \"_score\": 0.5753642}, \"rating\": 1}\
  \      ],\
  \      \"metric_details\": {\
  \        \"mean_reciprocal_rank\": {\"first_relevant\": 1}\
  \      }\
  \    }\
  \  },\
  \  \"failures\": {}\
  \}"

-- | A response with a non-empty 'rerResponseFailures' map: server may
-- report per-item errors when, e.g., the rated document does not exist
-- in the target index.
sampleRankEvalResponseWithFailure :: LBS.ByteString
sampleRankEvalResponseWithFailure =
  "{\
  \  \"rank_eval_score\": 0.0,\
  \  \"_shards\": {\"total\": 1, \"successful\": 1, \"skipped\": 0, \"failed\": 0},\
  \  \"details\": {},\
  \  \"failures\": {\
  \    \"bad_request\": {\
  \      \"reason\": \"rated document does not exist\",\
  \      \"type\": \"illegal_argument_exception\"\
  \    }\
  \  }\
  \}"

sampleRequest :: RankEvalRequest
sampleRequest =
  RankEvalRequest
    { rerRequests =
        [ RankEvalRequestItem
            { reiId = "q1",
              reiRequest = mkSearch (Just (MatchAllQuery Nothing)) Nothing,
              reiRatings =
                [ DocumentRating
                    { drIndex = testIndex,
                      drId = DocId "1",
                      drRating = 1,
                      drPassthrough = Nothing
                    }
                ],
              reiTemplateId = Nothing,
              reiParams = Nothing,
              reiSummaryFields = Nothing,
              reiIgnoreUnlabeled = Nothing
            }
        ],
      rerMetric = Nothing,
      rerMaxConcurrentSearches = Nothing
    }

-- ---------------------------------------------------------------------------
-- - Spec
-- ---------------------------------------------------------------------------

spec :: Spec
spec = do
  ---------------------------------------------------------------------------
  -- URI param rendering
  ---------------------------------------------------------------------------
  describe "RankEvalOptions URI param rendering" $ do
    it "defaultRankEvalOptions emits no params" $
      rankEvalOptionsParams defaultRankEvalOptions `shouldBe` []

    it "omits search_type when set to the server default (QueryThenFetch)" $ do
      let opts = defaultRankEvalOptions {reoSearchType = SearchTypeQueryThenFetch}
      rankEvalOptionsParams opts `shouldBe` []

    it "emits search_type only when set to DfsQueryThenFetch" $ do
      let opts = defaultRankEvalOptions {reoSearchType = SearchTypeDfsQueryThenFetch}
      rankEvalOptionsParams opts `shouldBe` [("search_type", Just "dfs_query_then_fetch")]

    it "renders Bool fields as true/false strings" $ do
      let opts =
            defaultRankEvalOptions
              { reoAllowNoIndices = Just True,
                reoIgnoreUnavailable = Just False
              }
      normalize (rankEvalOptionsParams opts)
        `shouldBe` [ ("allow_no_indices", Just "true"),
                     ("ignore_unavailable", Just "false")
                   ]

    it "renders expand_wildcards as a comma-separated list" $ do
      let opts =
            defaultRankEvalOptions
              { reoExpandWildcards = Just (ExpandWildcardsOpen :| [ExpandWildcardsClosed])
              }
      rankEvalOptionsParams opts
        `shouldBe` [("expand_wildcards", Just "open,closed")]

    it "emits every field when all are populated alongside DfsQueryThenFetch" $ do
      let opts =
            defaultRankEvalOptions
              { reoSearchType = SearchTypeDfsQueryThenFetch,
                reoAllowNoIndices = Just True,
                reoExpandWildcards = Just (ExpandWildcardsOpen :| []),
                reoIgnoreUnavailable = Just True
              }
      let rendered = rankEvalOptionsParams opts
      length rendered `shouldBe` 4
      sort (map fst rendered)
        `shouldBe` [ "allow_no_indices",
                     "expand_wildcards",
                     "ignore_unavailable",
                     "search_type"
                   ]

  ---------------------------------------------------------------------------
  -- RankEvalMetric JSON
  ---------------------------------------------------------------------------
  describe "RankEvalMetric JSON" $ do
    it "encodes MeanReciprocalRank under its server key" $
      encode (RankEvalMeanReciprocalRank defaultMeanReciprocalRankConfig)
        `shouldBe` "{\"mean_reciprocal_rank\":{}}"

    it "encodes MeanReciprocalRank with k when set" $
      encode (RankEvalMeanReciprocalRank defaultMeanReciprocalRankConfig {mrrcK = Just 10})
        `shouldBe` "{\"mean_reciprocal_rank\":{\"k\":10}}"

    it "encodes Precision under its server key" $
      encode (RankEvalPrecision defaultPrecisionConfig {pcK = Just 5})
        `shouldBe` "{\"precision\":{\"k\":5}}"

    it "encodes Precision with all three config fields" $ do
      -- NB: aeson 'object' sorts keys alphabetically; assert
      -- structurally rather than on the exact wire bytes.
      let encoded =
            encode
              ( RankEvalPrecision
                  defaultPrecisionConfig
                    { pcK = Just 5,
                      pcRelevantRatingThreshold = Just 1,
                      pcIgnoreUnlabeled = Just True
                    }
              )
      let Just (top :: Object) = decode encoded
          Just (Object (cfg :: Object)) = X.lookup "precision" top
      X.lookup "k" cfg `shouldBe` Just (Number 5)
      X.lookup "relevant_rating_threshold" cfg `shouldBe` Just (Number 1)
      X.lookup "ignore_unlabeled" cfg `shouldBe` Just (Bool True)

    it "encodes Recall under its server key" $
      encode (RankEvalRecall defaultRecallConfig {rcK = Just 10, rcRelevantRatingThreshold = Just 2})
        `shouldBe` "{\"recall\":{\"k\":10,\"relevant_rating_threshold\":2}}"

    it "encodes DCG as an empty object" $
      encode RankEvalDCG `shouldBe` "{\"dcg\":{}}"

    it "encodes NDCG as an empty object" $
      encode RankEvalNDCG `shouldBe` "{\"ndcg\":{}}"

    it "encodes ExpectedReciprocalRank with k and maximum_relevance" $
      encode
        ( RankEvalExpectedReciprocalRank
            defaultExpectedReciprocalRankConfig
              { errecK = Just 10,
                errecMaximumRelevance = Just 3
              }
        )
        `shouldBe` "{\"expected_reciprocal_rank\":{\"k\":10,\"maximum_relevance\":3}}"

    it "round-trips every constructor through ToJSON/FromJSON" $ do
      let metrics =
            [ RankEvalMeanReciprocalRank defaultMeanReciprocalRankConfig {mrrcK = Just 20},
              RankEvalPrecision defaultPrecisionConfig {pcK = Just 5, pcIgnoreUnlabeled = Just False},
              RankEvalRecall defaultRecallConfig {rcK = Just 10},
              RankEvalDCG,
              RankEvalNDCG,
              RankEvalExpectedReciprocalRank defaultExpectedReciprocalRankConfig {errecK = Just 5, errecMaximumRelevance = Just 2}
            ]
      forM_ metrics $ \m ->
        (decode . encode) m `shouldBe` Just m

    it "decodes a metric when an unknown extra key is present" $
      -- The decoder must not bail on extension keys it doesn't
      -- recognise; it picks the first known one.
      decode "{\"future_metric\":{}, \"precision\":{\"k\":3}}"
        `shouldBe` Just (RankEvalPrecision defaultPrecisionConfig {pcK = Just 3})

    it "fails when no recognised metric key is present" $ do
      let decoded = decode "{\"unknown_metric\":{}}" :: Maybe RankEvalMetric
      decoded `shouldBe` Nothing

  ---------------------------------------------------------------------------
  -- RankEvalRequest JSON
  ---------------------------------------------------------------------------
  describe "RankEvalRequest JSON" $ do
    it "encodes a bare request as {\"requests\":[...]}" $ do
      let Just (obj :: Object) = decode (encode sampleRequest)
      X.lookup "requests" obj `shouldSatisfy` isJust

    it "omits the metric key when rerMetric is Nothing" $ do
      let Just (obj :: Object) = decode (encode sampleRequest)
      X.lookup "metric" obj `shouldBe` Nothing

    it "includes the metric key when rerMetric is Just" $ do
      let req = sampleRequest {rerMetric = Just RankEvalDCG}
          Just (obj :: Object) = decode (encode req)
      X.lookup "metric" obj `shouldSatisfy` isJust

    it "omits max_concurrent_searches when rerMaxConcurrentSearches is Nothing" $ do
      let Just (obj :: Object) = decode (encode sampleRequest)
      X.lookup "max_concurrent_searches" obj `shouldBe` Nothing

    it "includes max_concurrent_searches as a number when rerMaxConcurrentSearches is Just" $ do
      let req = sampleRequest {rerMaxConcurrentSearches = Just 4}
          Just (obj :: Object) = decode (encode req)
      X.lookup "max_concurrent_searches" obj `shouldSatisfy` isJust

  ---------------------------------------------------------------------------
  -- DocumentRating JSON
  ---------------------------------------------------------------------------
  describe "DocumentRating JSON" $ do
    it "encodes the _index/_id/rating triplet and omits passthrough when Nothing" $ do
      -- NB: key order is unspecified; assert structurally.
      let Just (obj :: Object) =
            decode
              ( encode
                  ( DocumentRating
                      { drIndex = testIndex,
                        drId = DocId "1",
                        drRating = 2,
                        drPassthrough = Nothing
                      }
                  )
              )
      X.lookup "_index" obj `shouldBe` Just (toJSON testIndex)
      X.lookup "_id" obj `shouldBe` Just (toJSON (DocId "1"))
      X.lookup "rating" obj `shouldBe` Just (Number 2)
      X.lookup "passthrough" obj `shouldBe` Nothing

    it "encodes passthrough verbatim when present" $
      let passthrough = case object ["context" .= ("mobile" :: Text)] of Object o -> o; _ -> mempty
       in do
            let Just (obj :: Object) =
                  decode
                    ( encode
                        ( DocumentRating
                            { drIndex = testIndex,
                              drId = DocId "1",
                              drRating = 1,
                              drPassthrough = Just passthrough
                            }
                        )
                    )
            X.lookup "_index" obj `shouldBe` Just (toJSON testIndex)
            X.lookup "_id" obj `shouldBe` Just (toJSON (DocId "1"))
            X.lookup "rating" obj `shouldBe` Just (Number 1)
            -- passthrough carried through verbatim
            let Just (Object (pt :: Object)) = X.lookup "passthrough" obj
            X.lookup "context" pt `shouldBe` Just (String "mobile")

    it "round-trips through ToJSON/FromJSON (with passthrough)" $ do
      let passthrough = case object ["note" .= ("x" :: Text)] of Object o -> o; _ -> mempty
          original =
            DocumentRating
              { drIndex = testIndex,
                drId = DocId "1",
                drRating = 3,
                drPassthrough = Just passthrough
              }
      (decode . encode) original `shouldBe` Just original

    it "round-trips a fractional rating through ToJSON/FromJSON" $ do
      -- The rating is typed as 'Double' precisely so fractional
      -- relevance scores (which both ES and OS accept) survive a
      -- round-trip.
      let original =
            DocumentRating
              { drIndex = testIndex,
                drId = DocId "1",
                drRating = 0.75,
                drPassthrough = Nothing
              }
      (decode . encode) original `shouldBe` Just original

  ---------------------------------------------------------------------------
  -- RankEvalRequestItem: summary_fields / ignore_unlabeled
  ---------------------------------------------------------------------------
  describe "RankEvalRequestItem summary_fields / ignore_unlabeled" $ do
    it "omits summary_fields and ignore_unlabeled when both are Nothing" $ do
      let item =
            (head (rerRequests sampleRequest))
              { reiSummaryFields = Nothing,
                reiIgnoreUnlabeled = Nothing
              }
          Just (obj :: Object) = decode (encode item)
      X.lookup "summary_fields" obj `shouldBe` Nothing
      X.lookup "ignore_unlabeled" obj `shouldBe` Nothing

    it "renders summary_fields as a JSON array of field names" $ do
      let item =
            (head (rerRequests sampleRequest))
              { reiSummaryFields = Just [FieldName "message", FieldName "user"]
              }
          Just (obj :: Object) = decode (encode item)
      X.lookup "summary_fields" obj
        `shouldBe` Just (toJSON ["message" :: Text, "user"])

    it "renders ignore_unlabeled as a Bool when set" $ do
      let item =
            (head (rerRequests sampleRequest))
              { reiIgnoreUnlabeled = Just True
              }
          Just (obj :: Object) = decode (encode item)
      X.lookup "ignore_unlabeled" obj `shouldBe` Just (Bool True)

  ---------------------------------------------------------------------------
  -- RankEvalResponse JSON
  ---------------------------------------------------------------------------
  describe "RankEvalResponse JSON" $ do
    it "decodes the canonical response fixture, preserving details and metric_details" $ do
      let Just (decoded :: RankEvalResponse) = decode sampleRankEvalResponse
      rerResponseScore decoded `shouldSatisfy` (\x -> x > 0 && x <= 1)
      -- _shards total/successful parsed
      shardTotal (rerResponseShards decoded) `shouldBe` 1
      shardsSuccessful (rerResponseShards decoded) `shouldBe` 1
      -- details keyed by request id
      M.keysSet (rerResponseDetails decoded) `shouldBe` M.keysSet (M.singleton "coffee_beans" ())
      -- the metric_details shape survives
      let Just detail = M.lookup "coffee_beans" (rerResponseDetails decoded)
      redMetricScore detail `shouldBe` 1.0
      length (redHits detail) `shouldBe` 1
      -- failures present but empty
      rerResponseFailures decoded `shouldBe` M.empty

    it "decodes the OpenSearch-shaped fixture (metric_score key, hit envelope, no _shards)" $ do
      let Just (decoded :: RankEvalResponse) = decode sampleOpenSearchRankEvalResponse
      -- metric_score read in place of rank_eval_score.
      rerResponseScore decoded `shouldBe` 1.0
      -- No _shards block on OS → defaults to zero counts.
      shardTotal (rerResponseShards decoded) `shouldBe` 0
      let Just detail = M.lookup "coffee_beans" (rerResponseDetails decoded)
      length (redHits detail) `shouldBe` 1
      -- Hit fields extracted from the nested "hit" envelope.
      let hit = head (redHits detail)
      unIndexName (rehIndex hit) `shouldBe` "my-index-000001"
      rehRating hit `shouldBe` Just 1

    it "decodes a response with per-item failures into rerResponseFailures" $ do
      let Just (decoded :: RankEvalResponse) = decode sampleRankEvalResponseWithFailure
      M.keysSet (rerResponseFailures decoded) `shouldBe` M.keysSet (M.singleton "bad_request" ())
      rerResponseDetails decoded `shouldBe` M.empty

    it "treats missing 'details'/'failures' fields as empty maps" $ do
      let Just (decoded :: RankEvalResponse) =
            decode
              "{\
              \  \"rank_eval_score\": 0.5,\
              \  \"_shards\": {\"total\": 1, \"successful\": 1, \"skipped\": 0, \"failed\": 0}\
              \}"
      rerResponseDetails decoded `shouldBe` M.empty
      rerResponseFailures decoded `shouldBe` M.empty

  ---------------------------------------------------------------------------
  -- Endpoint wire shape
  ---------------------------------------------------------------------------
  describe "evaluateRank endpoint envelope" $ do
    it "evaluateRank targets /_rank_eval with no query parameters" $ do
      let req = Common.evaluateRank sampleRequest
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_rank_eval"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestMethod req `shouldBe` "POST"
      bhRequestBody req `shouldSatisfy` isJust

    it "evaluateRankByIndex targets /{index}/_rank_eval" $ do
      let req = Common.evaluateRankByIndex testIndex sampleRequest
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` [unIndexName testIndex, "_rank_eval"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "evaluateRankWith Nothing matches evaluateRank's path" $ do
      let reqA = Common.evaluateRank sampleRequest
          reqB = Common.evaluateRankWith Nothing defaultRankEvalOptions sampleRequest
      getRawEndpoint (bhRequestEndpoint reqA)
        `shouldBe` getRawEndpoint (bhRequestEndpoint reqB)

    it "evaluateRankWith (Just []) matches evaluateRank's path" $ do
      let reqA = Common.evaluateRank sampleRequest
          reqB = Common.evaluateRankWith (Just []) defaultRankEvalOptions sampleRequest
      getRawEndpoint (bhRequestEndpoint reqA)
        `shouldBe` getRawEndpoint (bhRequestEndpoint reqB)

    it "evaluateRankWith (Just [i]) matches evaluateRankByIndex's path" $ do
      let reqA = Common.evaluateRankByIndex testIndex sampleRequest
          reqB = Common.evaluateRankWith (Just [testIndex]) defaultRankEvalOptions sampleRequest
      getRawEndpoint (bhRequestEndpoint reqA)
        `shouldBe` getRawEndpoint (bhRequestEndpoint reqB)

    it "evaluateRankWith joins multiple indices with a comma" $ do
      let req =
            Common.evaluateRankWith
              (Just [testIndex, testIndex])
              defaultRankEvalOptions
              sampleRequest
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` [ unIndexName testIndex <> "," <> unIndexName testIndex,
                     "_rank_eval"
                   ]

    it "evaluateRankWith forwards RankEvalOptions as query parameters" $ do
      let opts =
            defaultRankEvalOptions
              { reoIgnoreUnavailable = Just True,
                reoExpandWildcards = Just (ExpandWildcardsOpen :| [])
              }
          req = Common.evaluateRankWith (Just [testIndex]) opts sampleRequest
      normalize (getRawEndpointQueries (bhRequestEndpoint req))
        `shouldBe` [ ("expand_wildcards", Just "open"),
                     ("ignore_unavailable", Just "true")
                   ]

  ---------------------------------------------------------------------------
  -- Live integration
  ---------------------------------------------------------------------------
  -- The local OpenSearch backend rejects requests with
  -- @metric = Nothing@ (@Required [metric]@); always set one in live
  -- tests even though the type allows it.
  let liveMetric = RankEvalMeanReciprocalRank defaultMeanReciprocalRankConfig {mrrcK = Just 10}
      sampleLiveRequest = sampleRequest {rerMetric = Just liveMetric}
  describe "evaluateRank (integration)" $ do
    it "evaluateRankByIndex scores the indexed tweet as relevant for a matching query" $
      withTestEnv $ do
        _ <- insertData
        let q = QueryMatchQuery $ mkMatchQuery (FieldName "message") (QueryString "haskell")
            item =
              RankEvalRequestItem
                { reiId = "haskell-search",
                  reiRequest = (mkSearch (Just q) Nothing) {size = Size 1},
                  reiRatings =
                    [ DocumentRating
                        { drIndex = testIndex,
                          drId = DocId "1",
                          drRating = 1,
                          drPassthrough = Nothing
                        }
                    ],
                  reiTemplateId = Nothing,
                  reiParams = Nothing,
                  reiSummaryFields = Nothing,
                  reiIgnoreUnlabeled = Nothing
                }
            req =
              RankEvalRequest
                { rerRequests = [item],
                  rerMetric = Just liveMetric,
                  rerMaxConcurrentSearches = Nothing
                }
        result <- tryPerformBHRequest $ Common.evaluateRankByIndex testIndex req
        case result of
          Left e ->
            liftIO $
              expectationFailure $
                "expected a successful rank-eval, got EsError: " <> show e
          Right resp ->
            liftIO $ do
              -- Perfect ranking => overall score is 1.0 across both
              -- MRR and precision@k. We accept >= 0.99 to leave room
              -- for floating-point noise.
              rerResponseScore resp `shouldSatisfy` (>= 0.99)
              -- The request id surfaces under details.
              M.keysSet (rerResponseDetails resp)
                `shouldBe` M.keysSet (M.singleton "haskell-search" ())
              -- No failures expected on a clean run.
              rerResponseFailures resp `shouldBe` M.empty

    it "evaluateRankByIndex surfaces per-request details with hits and unrated_docs" $
      withTestEnv $ do
        _ <- insertData
        -- Index a second document we deliberately do NOT rate, so the
        -- search returns it as an 'unrated_doc' in the breakdown.
        _ <- insertOther
        let q = QueryMatchQuery $ mkMatchQuery (FieldName "message") (QueryString "haskell")
            item =
              RankEvalRequestItem
                { reiId = "mixed",
                  reiRequest = (mkSearch (Just q) Nothing) {size = Size 10},
                  reiRatings =
                    [ DocumentRating
                        { drIndex = testIndex,
                          drId = DocId "1",
                          drRating = 1,
                          drPassthrough = Nothing
                        }
                    ],
                  reiTemplateId = Nothing,
                  reiParams = Nothing,
                  reiSummaryFields = Nothing,
                  reiIgnoreUnlabeled = Nothing
                }
            req =
              RankEvalRequest
                { rerRequests = [item],
                  rerMetric = Just liveMetric,
                  rerMaxConcurrentSearches = Nothing
                }
        result <- tryPerformBHRequest $ Common.evaluateRankByIndex testIndex req
        case result of
          Left e ->
            liftIO $
              expectationFailure $
                "expected a successful rank-eval, got EsError: " <> show e
          Right resp -> do
            let Just detail = M.lookup "mixed" (rerResponseDetails resp)
            liftIO $ do
              -- At least one hit was returned by the search.
              length (redHits detail) `shouldSatisfy` (>= 1)
              -- DocId "1" should be among the rated hits with rating 1.
              let ratedIds = [unpackId (rehId h) | h <- redHits detail]
              "1" `elem` ratedIds `shouldBe` True

    it "evaluateRankWith surfaces per-request failures for a missing index" $ do
      let missing = [qqIndexName|bloodhound-missing-index-rank-eval-spec|]
          req =
            Common.evaluateRankWith
              (Just [missing])
              defaultRankEvalOptions
              sampleLiveRequest
      -- Two equally valid responses exist across backends:
      --   * ES returns HTTP 404 → 'EsError' from 'tryEsError'.
      --   * OpenSearch returns HTTP 200 with @failures@ populated on
      --     the parsed 'RankEvalResponse' (and @metric_score: "NaN"@).
      -- Accept either: the only invariant we pin is "the missing
      -- index did not silently evaluate to a clean response".
      result <- withTestEnv $ tryEsError (performBHRequest req)
      case result of
        Left _ -> pure ()
        Right resp ->
          rerResponseFailures resp `shouldSatisfy` (not . M.null)

    it "evaluateRankWith forwards ignore_unavailable to keep a missing index from failing the request" $
      withTestEnv $ do
        _ <- insertData
        let missing = [qqIndexName|bloodhound-missing-index-rank-eval-spec|]
            opts = defaultRankEvalOptions {reoIgnoreUnavailable = Just True}
            req =
              Common.evaluateRankWith
                (Just [missing, testIndex])
                opts
                sampleLiveRequest
        -- The request should not raise an EsError. We don't assert on
        -- the score (the missing index is silently dropped, leaving
        -- testIndex to evaluate against itself), only on the absence
        -- of an error path.
        result <- tryEsError (performBHRequest req)
        case result of
          Left e ->
            liftIO $
              expectationFailure $
                "expected success with ignore_unavailable=true, got EsError: " <> show e
          Right _ -> pure ()