packages feed

kioku-core-0.1.0.0: test/Kioku/EmbeddingWorkerSpec.hs

{-# LANGUAGE DataKinds #-}

module Kioku.EmbeddingWorkerSpec
  ( tests,
  )
where

import Baikai.Embedding (EmbeddingModel)
import Data.Aeson qualified as Aeson
import Data.HashMap.Strict qualified as HashMap
import Data.Set qualified as Set
import Data.Vector qualified as Vector
import Effectful (Eff, IOE, (:>))
import Effectful.Error.Static (Error)
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Statement (Statement, preparable)
import Hasql.Transaction qualified as Tx
import Keiro.Stream qualified as Stream
import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..))
import Kioku.Api.Types (Confidence (..), MemoryType (..))
import Kioku.App (AppEffects, AppEnv, runAppIO, withNoopAppEnv)
import Kioku.Id (MemoryId, genMemoryId, idText)
import Kioku.Memory qualified as Memory
import Kioku.Memory.Domain (RecordMemoryData (..))
import Kioku.Memory.Embedding (EmbedError (..), EmbeddingConfig (..), toEmbeddingModel)
import Kioku.Memory.Embedding.Worker
  ( EmbeddingWorkerEnv (..),
    embeddingHandler,
    shouldSkipEmbedding,
  )
import Kioku.Memory.EventStream (memoryStream)
import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase)
import Kioku.Prelude
import Kioku.Recall.Capability (VectorCapability (..), detectVectorCapability)
import Kioku.Worker.Failure (embeddingRetryDelay, isTransientStoreError)
import Kiroku.Store.Connection (defaultConnectionSettings)
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Effect.Resource (KirokuStoreResource)
import Kiroku.Store.Error (StoreError (..))
import Kiroku.Store.Read (readStreamForward)
import Kiroku.Store.Transaction (runTransaction)
import Kiroku.Store.Types (ExpectedVersion (..), RecordedEvent (..), StreamName (..), StreamVersion (..))
import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..), RetryDelay (..))
import Shibuya.Core.AckHandle (AckHandle (..))
import Shibuya.Core.Ingested (Ingested (..))
import Shibuya.Core.Types (Attempt (..), Envelope (..), MessageId (..))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (Assertion, assertBool, assertFailure, testCase, (@?=))

tests :: TestTree
tests =
  testGroup
    "Embedding worker"
    [ testCase "skips only when the embedding exists and the content hash matches" do
        shouldSkipEmbedding True (Just "hash-a") "hash-a" @?= True
        shouldSkipEmbedding False (Just "hash-a") "hash-a" @?= False
        shouldSkipEmbedding True Nothing "hash-a" @?= False
        shouldSkipEmbedding True (Just "hash-b") "hash-a" @?= False,
      testCase "classifies only connection-shaped store errors as transient" testTransientClassification,
      testCase "backs off on the documented retry schedule" testRetrySchedule,
      testCase "provider failure acks retry" testProviderFailureRetries,
      testCase "undecodable payload acks dead-letter" testUndecodablePayloadDeadLetters,
      testCase "successful embedding acks ok and stores the vector" testSuccessStoresEmbedding,
      testCase "dimension mismatch halts the processor" testDimensionMismatchHalts
    ]

-- | Every constructor kiroku documents as retryable is transient; every
-- constructor describing a stable disagreement with the database is not. A new
-- kiroku constructor breaks this test's exhaustiveness at the source, not here.
testTransientClassification :: Assertion
testTransientClassification = do
  isTransientStoreError PoolAcquisitionTimeout @?= True
  isTransientStoreError (ConnectionLost "reset by peer") @?= True
  isTransientStoreError (ConnectionError "pool closed") @?= True
  isTransientStoreError (UnexpectedServerError "22000" "expected 1536 dimensions, not 8") @?= False
  isTransientStoreError (StreamNotFound (StreamName "kioku_memory-x")) @?= False
  isTransientStoreError (DuplicateEvent Nothing) @?= False
  isTransientStoreError (WrongExpectedVersion (StreamName "s") AnyVersion (StreamVersion 0)) @?= False
  isTransientStoreError (EmptyAppendBatch (StreamName "s")) @?= False
  isTransientStoreError (ReservedStreamName (StreamName "$all")) @?= False
  isTransientStoreError (StreamNameTooLong (StreamName "s") 9000) @?= False
  isTransientStoreError (StreamAlreadyExists (StreamName "s")) @?= False
  isTransientStoreError (EventAlreadyLinked (StreamName "s") Nothing) @?= False
  isTransientStoreError (LinkSourceEventMissing (StreamName "s")) @?= False

testRetrySchedule :: Assertion
testRetrySchedule = do
  embeddingRetryDelay (Just (Attempt 0)) @?= RetryDelay 5
  embeddingRetryDelay (Just (Attempt 1)) @?= RetryDelay 20
  embeddingRetryDelay (Just (Attempt 2)) @?= RetryDelay 60
  embeddingRetryDelay (Just (Attempt 3)) @?= RetryDelay 180
  -- An adapter that does not track redeliveries gets the longest delay.
  embeddingRetryDelay Nothing @?= RetryDelay 180

-- | A provider outage must not be acked as success: the event is redelivered
-- with the attempt-indexed backoff, and kiroku's retry policy bounds it.
testProviderFailureRetries :: Assertion
testProviderFailureRetries =
  withVectorEnv "provider failure" \env capability -> do
    decision <-
      runHandler env capability (failingEmbed (EmbedTransport "provider is down")) (Just 0)
    decision @?= AckRetry (embeddingRetryDelay (Just (Attempt 0)))

-- | A payload that cannot be decoded can never succeed. It goes to the
-- dead-letter table with a reason instead of vanishing behind an 'AckOk'.
-- Not gated on pgvector: the handler decides before it touches the memory row.
testUndecodablePayloadDeadLetters :: Assertion
testUndecodablePayloadDeadLetters =
  withEmbeddingEnv \appEnv -> do
    capability <- runOrFail appEnv (detectVectorCapability embeddingDims)
    decision <- runOrFail appEnv do
      (_, recorded) <- recordFixtureMemory "corrupt payload memory"
      let env = mkTestEnv (failingEmbed EmbedEmpty)
      embeddingHandler capability env (mkIngested (corruptPayload recorded) (Just 0))
    case decision of
      AckDeadLetter (InvalidPayload _) -> pure ()
      other -> assertFailure ("expected a dead-letter for an undecodable payload, got: " <> show other)

-- | The happy path still works, and it actually writes: 'AckOk' is only honest
-- if the vector landed in the row.
testSuccessStoresEmbedding :: Assertion
testSuccessStoresEmbedding =
  withVectorEnv "successful embedding" \env capability -> do
    (decision, stored) <-
      runOrFail env do
        (memoryId, recorded) <- recordFixtureMemory "a memory worth embedding"
        let workerEnv = mkTestEnv (\_ -> pure (Right (Vector.replicate embeddingDims 0.1)))
        decision <- embeddingHandler capability workerEnv (mkIngested recorded (Just 0))
        stored <- loadEmbeddingState (idText memoryId)
        pure (decision, stored)
    decision @?= AckOk
    assertBool "the memory row has an embedding and a content hash" stored

-- | The one case where halting is right. A dimension mismatch is a permanent,
-- systemic store error: every subsequent event would fail identically, so
-- dead-lettering would quietly drain the whole stream.
testDimensionMismatchHalts :: Assertion
testDimensionMismatchHalts =
  withVectorEnv "dimension mismatch" \env capability -> do
    decision <-
      runHandler env capability (\_ -> pure (Right (Vector.replicate 8 0.1))) (Just 0)
    case decision of
      AckHalt (HaltFatal _) -> pure ()
      other -> assertFailure ("expected a fatal halt on a dimension mismatch, got: " <> show other)

-- | Record a memory, then hand back its id and the @MemoryRecorded@ event at
-- the head of its stream — a real recorded event, not a hand-built one.
recordFixtureMemory ::
  (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>
  Text ->
  Eff es (MemoryId, RecordedEvent)
recordFixtureMemory content = do
  memoryId <- liftIO genMemoryId
  now <- liftIO getCurrentTime
  recorded <-
    Memory.record
      RecordMemoryData
        { memoryId,
          agentId = "test-agent",
          sessionId = Nothing,
          scope = fixtureScope,
          memoryType = MemoryPreference,
          content,
          priority = 5,
          confidence = HighConfidence,
          tags = Set.fromList ["embedding-worker-spec"],
          supersedes = Nothing,
          recordedAt = now
        }
  void (liftIO (expectRight "Memory.record" recorded))
  events <- readStreamForward (Stream.streamName (memoryStream memoryId)) (StreamVersion 0) 10
  case Vector.toList events of
    event : _ -> pure (memoryId, event)
    [] -> liftIO (assertFailure "the recorded memory has no events")

-- | Replace the event payload with something the memory codec cannot decode.
-- Spelled out field by field rather than as a record update because @payload@
-- is also an 'Envelope' field, and GHC will not guess which one is meant.
corruptPayload :: RecordedEvent -> RecordedEvent
corruptPayload e =
  RecordedEvent
    { eventId = e.eventId,
      eventType = e.eventType,
      streamVersion = e.streamVersion,
      globalPosition = e.globalPosition,
      originalStreamId = e.originalStreamId,
      originalVersion = e.originalVersion,
      payload = Aeson.String "garbage",
      metadata = e.metadata,
      causationId = e.causationId,
      correlationId = e.correlationId,
      createdAt = e.createdAt
    }

runHandler ::
  AppEnv ->
  VectorCapability ->
  (Text -> IO (Either EmbedError (Vector.Vector Double))) ->
  Maybe Word ->
  IO AckDecision
runHandler appEnv capability embed attemptN =
  runOrFail appEnv do
    (_, recorded) <- recordFixtureMemory "a memory to embed"
    embeddingHandler capability (mkTestEnv embed) (mkIngested recorded attemptN)

mkTestEnv :: (Text -> IO (Either EmbedError (Vector.Vector Double))) -> EmbeddingWorkerEnv
mkTestEnv embed =
  EmbeddingWorkerEnv {model = testModel, dimensions = embeddingDims, embed}

failingEmbed :: EmbedError -> (Text -> IO (Either EmbedError (Vector.Vector Double)))
failingEmbed err _ = pure (Left err)

-- | The adapter's envelope, built by hand: only 'attempt' and 'payload' matter
-- to the handler, and the ack handle is never finalized because the handler
-- returns its decision rather than committing it.
mkIngested :: RecordedEvent -> Maybe Word -> Ingested es RecordedEvent
mkIngested recorded attemptN =
  Ingested
    { envelope =
        Envelope
          { messageId = MessageId "embedding-worker-spec",
            cursor = Nothing,
            partition = Nothing,
            enqueuedAt = Nothing,
            traceContext = Nothing,
            headers = Nothing,
            attempt = Attempt <$> attemptN,
            attributes = HashMap.empty,
            payload = recorded
          },
      ack = AckHandle {finalize = \_ -> pure ()},
      lease = Nothing
    }

-- | Run a database-backed case, skipping it when the ephemeral PostgreSQL has
-- no pgvector: without the extension the embedding column does not exist, so
-- the store-error branches under test cannot be reached at all.
withVectorEnv :: String -> (AppEnv -> VectorCapability -> IO ()) -> Assertion
withVectorEnv label action =
  withEmbeddingEnv \appEnv -> do
    capability <- runOrFail appEnv (detectVectorCapability embeddingDims)
    case capability of
      VectorAvailable -> action appEnv capability
      _ ->
        putStrLn
          ( "  [skipped: "
              <> label
              <> "] pgvector is unavailable in the ephemeral database ("
              <> show capability
              <> ")"
          )

withEmbeddingEnv :: (AppEnv -> IO a) -> IO a
withEmbeddingEnv action =
  withKiokuMigratedDatabase \connStr ->
    withNoopAppEnv (defaultConnectionSettings connStr) action

loadEmbeddingState :: (Store :> es) => Text -> Eff es Bool
loadEmbeddingState memoryId =
  runTransaction (Tx.statement memoryId selectEmbeddedStmt)

selectEmbeddedStmt :: Statement Text Bool
selectEmbeddedStmt =
  preparable
    """
    SELECT embedding IS NOT NULL AND content_hash IS NOT NULL
    FROM kiroku.kioku_memories
    WHERE memory_id = $1
    """
    (E.param (E.nonNullable E.text))
    (D.singleRow (D.column (D.nonNullable D.bool)))

fixtureScope :: MemoryScope
fixtureScope =
  ScopeEntity (Namespace "rei") (ScopeKind "intention") "intention_embedding_worker_spec"

testModel :: EmbeddingModel
testModel =
  toEmbeddingModel
    EmbeddingConfig
      { baseUrl = "http://embedding.invalid",
        model = "text-embedding-3-small",
        dimensions = embeddingDims,
        apiKey = "not-used-the-provider-is-faked"
      }

-- | Matches the @vector(1536)@ column the embedding migration creates.
embeddingDims :: Int
embeddingDims = 1536

runOrFail :: AppEnv -> Eff AppEffects a -> IO a
runOrFail appEnv action = runAppIO appEnv action >>= expectRight "runAppIO"

expectRight :: (Show e) => String -> Either e a -> IO a
expectRight label = \case
  Left err -> assertFailure (label <> " failed: " <> show err)
  Right value -> pure value