packages feed

kioku-core-0.4.0.0: src/Kioku/Memory/Embedding/Worker.hs

-- | The embedding worker: it computes a vector for each memory's content and writes it back onto
-- the same row.
--
-- Like the distillation timers, this worker discovers its own work, so it cannot arrive holding
-- an authorization context. It takes the memory space out of the delivered @MemoryRecorded@
-- event and asks a 'MemoryContextProvider' for a decision about /that/ space; a refusal
-- dead-letters, because a worker that is not allowed to embed a space is a configuration fact
-- and retrying it every second would hide it.
--
-- Three things then carry the partition, and each closes a different hole:
--
-- * the state read returns the row's /own/ space, and a disagreement with the envelope is
--   'EmbedSpaceMismatch' — dead-lettered, never mutated. Scoping that read by the envelope's
--   space instead would turn the disagreement into "no such memory", which acks as a success;
-- * the update names the space as well as the id, so a redelivery cannot enrich a row outside
--   the space its event named however stale the envelope has become;
-- * the backfill scan takes an 'EmbeddingBackfillScope', so an operator can run the pass for one
--   space rather than for every space in the database.
--
-- None of this is an authorization boundary in the sense recall is: a memory's embedding is a
-- property of the memory, no content reaches a caller, and a process holding the database
-- credentials may already act in any space in that database — which is what
-- 'Kioku.Api.Access.assumeAuthorizedContextProvider' says out loud. It is a /durable work
-- identity/ boundary: at-least-once delivery means the same envelope is handled repeatedly, and
-- every one of those attempts must land in the space the event named.
module Kioku.Memory.Embedding.Worker
  ( EmbeddingWorkerEnv (..),
    EmbedOutcome (..),
    EmbeddingBackfillScope (..),
    backfillMissingEmbeddings,
    embeddingHandler,
    embeddingWorkerProcessor,
    mkEmbeddingWorkerEnv,
    runEmbeddingWorkerHost,
    shouldSkipEmbedding,
  )
where

import Baikai.Embedding (EmbeddingModel (..))
import Control.Monad (foldM)
import Data.Functor.Contravariant ((>$<))
import Data.Int (Int32)
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Vector (Vector)
import Data.Vector qualified as Vector
import Effectful (Eff, IOE, (:>))
import Effectful.Error.Static (Error)
import Effectful.Error.Static qualified as EffError
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.Codec (decodeRecorded)
import Kioku.Api.Access
  ( MemoryContextProvider (..),
    MemoryPermission (..),
    MemorySpaceId,
    memoryContextAllows,
    memoryContextSpace,
    memorySpaceIdText,
  )
import Kioku.Database.Schema (memoriesTable)
import Kioku.Id (MemoryId, idText)
import Kioku.Memory.Domain (MemoryEvent (..), MemoryRecordedData (..))
import Kioku.Memory.Embedding (EmbedError, embedWithRetry, sha256Hex)
import Kioku.Memory.EventStream (memoryCodec)
import Kioku.Partition (memorySpaceColumn, memorySpaceParam)
import Kioku.Prelude
import Kioku.Recall.Capability (VectorCapability (..))
import Kioku.Worker.Failure (embeddingRetryDelay, isTransientStoreError)
import Kiroku.Store.Connection (KirokuStore)
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Transaction (runTransaction)
import Kiroku.Store.Types (CategoryName (..), EventType (..), RecordedEvent)
import Shibuya.Adapter.Kiroku
  ( EventTypeFilter (..),
    KirokuAdapterConfig (..),
    SubscriptionName (..),
    SubscriptionTarget (..),
    defaultKirokuAdapterConfig,
    guardKirokuHandler,
    kirokuAdapter,
  )
import Shibuya.App (ProcessorId (..), QueueProcessor (..), defaultAppConfig, runApp, waitApp)
import Shibuya.Core.Ack (AckDecision (..), DeadLetterReason (..), HaltReason (..))
import Shibuya.Core.Ingested (Ingested (..), Message (..))
import Shibuya.Core.Types (Envelope (..))
import Shibuya.Policy (Concurrency (..), OrderingPolicy (..))
import Shibuya.Telemetry.Effect (Tracing)
import System.IO qualified as IO

data EmbeddingCandidate = EmbeddingCandidate
  { memorySpaceId :: !MemorySpaceId,
    memoryId :: !Text,
    content :: !Text,
    contentHash :: !(Maybe Text),
    hasEmbedding :: !Bool
  }
  deriving stock (Generic, Eq, Show)

data EmbeddingUpdate = EmbeddingUpdate
  { memorySpaceId :: !MemorySpaceId,
    memoryId :: !Text,
    embedding :: !(Vector Double),
    embeddingModel :: !Text,
    dimensions :: !Int,
    contentHash :: !Text
  }
  deriving stock (Generic, Eq, Show)

-- | What the row itself says, including which space it is in.
--
-- The space is read back rather than asserted because that is the only way the handler can tell
-- a stale envelope from a missing memory. See 'EmbedSpaceMismatch'.
data EmbeddingState = EmbeddingState
  { memorySpaceId :: !MemorySpaceId,
    contentHash :: !(Maybe Text),
    hasEmbedding :: !Bool
  }
  deriving stock (Generic, Eq, Show)

-- | Which memory spaces one backfill pass covers.
--
-- 'BackfillEverySpace' is what the continuous worker runs at startup: it serves every space the
-- database holds, so recovering embeddings for only one of them would leave the rest silently
-- unsearchable. 'BackfillOneSpace' is for an operator repairing a single tenant, and for the
-- case where a pass over every space would be too large to finish.
data EmbeddingBackfillScope
  = BackfillEverySpace
  | BackfillOneSpace !MemorySpaceId
  deriving stock (Generic, Eq, Show)

-- | Everything the embedding path needs from the outside world.
--
-- The provider call is a field rather than a direct 'embedWithRetry' call so
-- tests can drive every branch of the ack taxonomy — a failing provider, a
-- succeeding one, one that returns the wrong number of dimensions — without an
-- embedding API key or a network.
data EmbeddingWorkerEnv = EmbeddingWorkerEnv
  { model :: !EmbeddingModel,
    dimensions :: !Int,
    embed :: !(Text -> IO (Either EmbedError (Vector Double)))
  }
  deriving stock (Generic)

-- | The production environment: the real provider, retried three times
-- in-process (~0.6s of jitter-free backoff) before the failure is reported to
-- the caller, which then decides whether the /event/ should be redelivered.
mkEmbeddingWorkerEnv :: EmbeddingModel -> Int -> EmbeddingWorkerEnv
mkEmbeddingWorkerEnv model dims =
  EmbeddingWorkerEnv {model, dimensions = dims, embed = embedWithRetry model 3}

-- | What one embedding attempt did.
--
-- 'EmbedSkipped' covers both "already embedded with this exact content" and
-- "the memory is not there to embed"; neither is a failure. The distinction
-- that matters to the handler is 'EmbedFailed', which used to be indistinguishable
-- from success.
--
-- 'EmbedSpaceMismatch' is the one outcome that must never be quiet. It means a delivered event
-- named one memory space and the row it names is in another, which is a forged or corrupt
-- envelope rather than an ordinary failure — no retry can fix it and nothing was written. It
-- carries the envelope's space first and the row's second.
data EmbedOutcome
  = EmbedSkipped
  | EmbedStored
  | EmbedFailed !EmbedError
  | EmbedSpaceMismatch !MemorySpaceId !MemorySpaceId
  deriving stock (Generic, Eq, Show)

runEmbeddingWorkerHost ::
  (IOE :> es, Store :> es, Error StoreError :> es, Tracing :> es) =>
  KirokuStore ->
  MemoryContextProvider (Eff es) ->
  VectorCapability ->
  EmbeddingModel ->
  Int ->
  Eff es ()
runEmbeddingWorkerHost store contexts capability model dims = do
  processor <- embeddingWorkerProcessor contexts capability model dims store
  started <- runApp defaultAppConfig [processor]
  case started of
    Left appErr ->
      liftIO (ioError (userError ("kioku embedding worker failed to start: " <> show appErr)))
    Right appHandle -> do
      liftIO (putStrLn "kioku embedding worker started. Press Ctrl+C to stop.")
      waitApp appHandle

embeddingWorkerProcessor ::
  (IOE :> es, Store :> es, Error StoreError :> es) =>
  MemoryContextProvider (Eff es) ->
  VectorCapability ->
  EmbeddingModel ->
  Int ->
  KirokuStore ->
  Eff es (ProcessorId, QueueProcessor es)
embeddingWorkerProcessor contexts capability model dims store = do
  adapter <- kirokuAdapter store embeddingAdapterConfig
  pure
    ( ProcessorId embeddingWorkerName,
      QueueProcessor
        { adapter,
          -- The kiroku bridge is ack-coupled: a synchronous exception escaping
          -- the handler leaves the ack unfinalized and blocks the subscription
          -- worker forever. The guard turns that into a one-second retry.
          handler = guardKirokuHandler (embeddingMessageHandler contexts capability (mkEmbeddingWorkerEnv model dims)),
          ordering = StrictInOrder,
          concurrency = Serial
        }
    )

-- | Decide what happens to one delivered @MemoryRecorded@ event.
--
-- Every branch is a deliberate choice about durability:
--
-- * a provider failure is /transient/ — retry with backoff, and let kiroku's
--   retry policy dead-letter it if the outage outlasts the window;
-- * an undecodable payload can never succeed — dead-letter it visibly rather
--   than acking it into the void;
-- * a transient store error must not kill the pipeline — retry;
-- * a permanent store error (a dimension mismatch, a broken schema) would fail
--   identically for every subsequent event — halting is the honest response,
--   because dead-lettering would quietly drain the whole stream.
--
-- Two branches are about the partition rather than about durability. A provider that refuses
-- this event's memory space dead-letters, matching 'Kioku.Distill.Timer.Worker.fireL1Timer': a
-- worker that may not embed a space is a configuration fact, and an operator requeues the
-- dead-letter row once it is fixed. An envelope whose space disagrees with the row's own space
-- dead-letters too, and writes nothing.
embeddingHandler ::
  (IOE :> es, Store :> es, Error StoreError :> es) =>
  MemoryContextProvider (Eff es) ->
  VectorCapability ->
  EmbeddingWorkerEnv ->
  Ingested es RecordedEvent ->
  Eff es AckDecision
embeddingHandler contexts capability env ingested =
  handleEmbeddingEnvelope contexts capability env ingested.envelope

embeddingMessageHandler ::
  (IOE :> es, Store :> es, Error StoreError :> es) =>
  MemoryContextProvider (Eff es) ->
  VectorCapability ->
  EmbeddingWorkerEnv ->
  Message es RecordedEvent ->
  Eff es AckDecision
embeddingMessageHandler contexts capability env message =
  handleEmbeddingEnvelope contexts capability env message.envelope

handleEmbeddingEnvelope ::
  (IOE :> es, Store :> es, Error StoreError :> es) =>
  MemoryContextProvider (Eff es) ->
  VectorCapability ->
  EmbeddingWorkerEnv ->
  Envelope RecordedEvent ->
  Eff es AckDecision
handleEmbeddingEnvelope contexts capability env envelope =
  EffError.catchError @StoreError run \_callStack storeErr ->
    if isTransientStoreError storeErr
      then do
        logWorker ("transient store error, retrying: " <> Text.pack (show storeErr))
        pure (AckRetry retryDelay)
      else do
        logWorker ("fatal store error, halting: " <> Text.pack (show storeErr))
        pure (AckHalt (HaltFatal ("kioku embedding worker store error: " <> Text.pack (show storeErr))))
  where
    retryDelay = embeddingRetryDelay envelope.attempt

    run =
      case decodeRecorded memoryCodec envelope.payload of
        Left codecErr -> do
          logWorker ("undecodable event, dead-lettering: " <> Text.pack (show codecErr))
          pure (AckDeadLetter (InvalidPayload (Text.pack (show codecErr))))
        Right (MemoryRecorded d) -> do
          decision <- contexts.contextForSpace d.memorySpaceId
          case decision of
            Left denial -> do
              let reason =
                    "not authorized to embed memory space "
                      <> memorySpaceIdText d.memorySpaceId
                      <> ": "
                      <> Text.pack (show denial)
              logWorker ("dead-lettering: " <> reason)
              pure (AckDeadLetter (InvalidPayload reason))
            Right context
              | not (memoryContextAllows MemoryDistill context) -> do
                  let reason =
                        "context for memory space "
                          <> memorySpaceIdText d.memorySpaceId
                          <> " does not grant distill"
                  logWorker ("dead-lettering: " <> reason)
                  pure (AckDeadLetter (InvalidPayload reason))
              | otherwise -> do
                  outcome <-
                    embedMemoryContent
                      capability
                      env
                      (memoryContextSpace context)
                      (idText (d.memoryId :: MemoryId))
                      d.content
                  case outcome of
                    EmbedFailed err -> do
                      logWorker ("embedding failed, retrying: " <> Text.pack (show err))
                      pure (AckRetry retryDelay)
                    EmbedSpaceMismatch expected actual -> do
                      let reason =
                            "event claims memory space "
                              <> memorySpaceIdText expected
                              <> " but memory "
                              <> idText (d.memoryId :: MemoryId)
                              <> " is in "
                              <> memorySpaceIdText actual
                      logWorker ("dead-lettering: " <> reason)
                      pure (AckDeadLetter (InvalidPayload reason))
                    EmbedStored -> pure AckOk
                    EmbedSkipped -> pure AckOk
        -- The subscription is filtered to MemoryRecorded, so this is unreachable
        -- today; acking is the harmless answer if the filter ever widens.
        Right _ -> pure AckOk

logWorker :: (IOE :> es) => Text -> Eff es ()
logWorker msg =
  liftIO (IO.hPutStrLn IO.stderr (Text.unpack (embeddingWorkerName <> ": " <> msg)))

-- | Embed every active memory that is missing a current vector, in one space or in all of them.
--
-- A candidate carries the space it was read from, so the update writes back into that same
-- space. There is no mismatch branch here and there cannot be one: unlike the subscription
-- handler, this pass has no envelope to disagree with the row.
-- It takes a whole 'EmbeddingWorkerEnv' rather than a model and a dimension count, for the
-- reason that record exists: a test can drive the pass with a fake provider, which is the only
-- way to assert /which rows/ a scope selected without an embedding API key and a network.
backfillMissingEmbeddings ::
  (IOE :> es, Store :> es) =>
  VectorCapability ->
  EmbeddingWorkerEnv ->
  EmbeddingBackfillScope ->
  Eff es Int
backfillMissingEmbeddings VectorAvailable env scope = do
  candidates <- runTransaction candidateQuery
  foldM embedCandidate 0 candidates
  where
    candidateQuery =
      case scope of
        BackfillEverySpace -> Tx.statement () selectEmbeddingCandidatesStmt
        BackfillOneSpace space -> Tx.statement space selectEmbeddingCandidatesInSpaceStmt

    embedCandidate count candidate
      | shouldSkipEmbedding candidate.hasEmbedding candidate.contentHash contentHash =
          pure count
      | otherwise = do
          outcome <-
            embedAndStore env candidate.memorySpaceId candidate.memoryId candidate.content contentHash
          case outcome of
            EmbedStored -> pure (count + 1)
            EmbedSkipped -> pure count
            -- One unembeddable memory must not abort the pass: a backfill exists
            -- precisely to recover from failures, and the next run retries this row.
            EmbedFailed err -> do
              logWorker ("backfill skipped " <> candidate.memoryId <> ": " <> Text.pack (show err))
              pure count
            -- Unreachable: the candidate's space came from the row being updated.
            EmbedSpaceMismatch expected actual -> do
              logWorker
                ( "backfill skipped "
                    <> candidate.memoryId
                    <> ": read in "
                    <> memorySpaceIdText expected
                    <> " but now in "
                    <> memorySpaceIdText actual
                )
              pure count
      where
        contentHash = sha256Hex candidate.content
backfillMissingEmbeddings _ _ _ = pure 0

-- | Embed one memory, refusing to touch it if it is not in the space the caller named.
--
-- The state read is keyed by the memory id alone, which is globally unique, and returns the
-- row's own space. That is deliberate and is the opposite of a leak: reading the space in order
-- to compare it is what makes a disagreement loud. Scoping the read by @space AND id@ would
-- report a memory in another space as absent, and absent is an ack.
embedMemoryContent ::
  (IOE :> es, Store :> es) =>
  VectorCapability ->
  EmbeddingWorkerEnv ->
  MemorySpaceId ->
  Text ->
  Text ->
  Eff es EmbedOutcome
embedMemoryContent VectorAvailable env memorySpaceId memoryId content = do
  existing <- runTransaction (Tx.statement memoryId selectEmbeddingStateStmt)
  case existing of
    Nothing -> pure EmbedSkipped
    Just state
      | state.memorySpaceId /= memorySpaceId ->
          pure (EmbedSpaceMismatch memorySpaceId state.memorySpaceId)
      | shouldSkipEmbedding state.hasEmbedding state.contentHash contentHash ->
          pure EmbedSkipped
      | otherwise ->
          embedAndStore env memorySpaceId memoryId content contentHash
  where
    contentHash = sha256Hex content
embedMemoryContent _ _ _ _ _ = pure EmbedSkipped

shouldSkipEmbedding :: Bool -> Maybe Text -> Text -> Bool
shouldSkipEmbedding hasEmbedding storedContentHash contentHash =
  hasEmbedding && storedContentHash == Just contentHash

embedAndStore ::
  (IOE :> es, Store :> es) =>
  EmbeddingWorkerEnv ->
  MemorySpaceId ->
  Text ->
  Text ->
  Text ->
  Eff es EmbedOutcome
embedAndStore env memorySpaceId memoryId content contentHash = do
  result <- liftIO (env.embed content)
  case result of
    Left err -> pure (EmbedFailed err)
    Right embedding -> do
      runTransaction $
        Tx.statement
          EmbeddingUpdate
            { memorySpaceId,
              memoryId,
              embedding,
              embeddingModel = env.model.modelId,
              dimensions = env.dimensions,
              contentHash
            }
          upsertEmbeddingStmt
      pure EmbedStored

selectEmbeddingCandidatesStmt :: Statement () [EmbeddingCandidate]
selectEmbeddingCandidatesStmt =
  preparable
    ( """
      SELECT memory_space_id, memory_id, content, content_hash, embedding IS NOT NULL AS has_embedding
      FROM
      """
        <> " "
        <> memoriesTable
        <> " "
        <> """
           WHERE status = 'active'
           ORDER BY created_at ASC
           """
    )
    E.noParams
    (D.rowList embeddingCandidateDecoder)

-- | The same scan bounded to one space, so an operator can repair one tenant.
--
-- @memory_space_id@ leads @kioku_memories_space_namespace_idx@, but this predicate carries no
-- namespace and orders by @created_at@, so the planner is free to prefer a scan. That is
-- correct: a backfill visits every unembedded row in the space by definition, and the point of
-- the predicate here is which rows are eligible, not how they are reached.
selectEmbeddingCandidatesInSpaceStmt :: Statement MemorySpaceId [EmbeddingCandidate]
selectEmbeddingCandidatesInSpaceStmt =
  preparable
    ( """
      SELECT memory_space_id, memory_id, content, content_hash, embedding IS NOT NULL AS has_embedding
      FROM
      """
        <> " "
        <> memoriesTable
        <> " "
        <> """
           WHERE status = 'active' AND memory_space_id = $1
           ORDER BY created_at ASC
           """
    )
    memorySpaceParam
    (D.rowList embeddingCandidateDecoder)

selectEmbeddingStateStmt :: Statement Text (Maybe EmbeddingState)
selectEmbeddingStateStmt =
  preparable
    ( """
      SELECT memory_space_id, content_hash, embedding IS NOT NULL AS has_embedding
      FROM
      """
        <> " "
        <> memoriesTable
        <> " "
        <> """
           WHERE memory_id = $1 AND status = 'active'
           """
    )
    (E.param (E.nonNullable E.text))
    (D.rowMaybe embeddingStateDecoder)

embeddingCandidateDecoder :: D.Row EmbeddingCandidate
embeddingCandidateDecoder =
  EmbeddingCandidate
    <$> memorySpaceColumn
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nonNullable D.bool)

embeddingStateDecoder :: D.Row EmbeddingState
embeddingStateDecoder =
  EmbeddingState
    <$> memorySpaceColumn
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nonNullable D.bool)

-- | The write names the space as well as the id.
--
-- 'embedMemoryContent' has already compared the two, so this predicate can never be the thing
-- that rejects a row — but the comparison and the write are two statements, and between them a
-- memory could in principle be rewritten into another space. The predicate is what makes the
-- write itself, rather than a check that preceded it, the thing that is partition-safe.
upsertEmbeddingStmt :: Statement EmbeddingUpdate ()
upsertEmbeddingStmt =
  preparable
    ( "UPDATE "
        <> memoriesTable
        <> "\n"
        <> """
           SET embedding = $3::vector,
               embedding_model = $4,
               dimensions = $5,
               content_hash = $6
           WHERE memory_space_id = $1 AND memory_id = $2
           """
    )
    embeddingUpdateEncoder
    D.noResult

embeddingUpdateEncoder :: E.Params EmbeddingUpdate
embeddingUpdateEncoder =
  ((\update -> update.memorySpaceId) >$< memorySpaceParam)
    <> ((\update -> update.memoryId) >$< E.param (E.nonNullable E.text))
    <> ((\update -> vectorLiteral update.embedding) >$< E.param (E.nonNullable E.text))
    <> ((\update -> update.embeddingModel) >$< E.param (E.nonNullable E.text))
    <> ((\update -> fromIntegral @Int @Int32 update.dimensions) >$< E.param (E.nonNullable E.int4))
    <> ((\update -> update.contentHash) >$< E.param (E.nonNullable E.text))

vectorLiteral :: Vector Double -> Text
vectorLiteral values =
  "[" <> Text.intercalate "," (Text.pack . show <$> Vector.toList values) <> "]"

embeddingAdapterConfig :: KirokuAdapterConfig
embeddingAdapterConfig =
  (defaultKirokuAdapterConfig (SubscriptionName embeddingWorkerName) (Category (CategoryName "kioku_memory")))
    { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "MemoryRecorded"])
    }

embeddingWorkerName :: Text
embeddingWorkerName = "kioku-memory-embedding"