packages feed

kioku-api-0.4.0.0: src/Kioku/Api/Recall.hs

-- | What a recall call searches, said out loud.
--
-- Recall has always been able to do two quite different things, and until this module existed it
-- said both of them with the same value. A 'Kioku.Api.Scope.MemoryScope' of
-- @ScopeGlobal (Namespace "mori")@ handed to recall meant /every scope in the namespace/ — the
-- scope filter vanished and entity-scoped rows came back too — while the same value handed to
-- 'Kioku.Recall.getActiveByScope' meant /the global bucket only/. Both behaviours are wanted.
-- Neither is wrong. But one value naming both of them is a defect you cannot see at a call site,
-- and the difference is how many rows a caller gets and which ones.
--
-- 'RecallTarget' names the two meanings apart:
--
-- * @'ExactScope' scope@ searches exactly that scope. @'ExactScope' ('Kioku.Api.Scope.ScopeGlobal'
--   ns)@ is the global bucket of @ns@ and nothing else — a request that had no representation at
--   all before this type.
-- * @'NamespaceWide' ns@ searches every scope in @ns@.
--
-- __The target never selects a tenant.__ A memory space is the isolation boundary
-- ('Kioku.Api.Access.MemorySpaceId'), and it is supplied at execution by the
-- 'Kioku.Api.Access.MemoryAccessContext' that authorized the call — never by the target.
-- "Namespace-wide" therefore means every scope in one namespace /of one already authorized
-- space/. Widening what you search must never widen who you are.
--
-- Nothing here knows about SQL, PostgreSQL, or the effect stack; this module is the vocabulary,
-- and "Kioku.Recall" is where a target is executed.
module Kioku.Api.Recall
  ( -- * What a recall call targets
    RecallTarget (..),
    recallTargetNamespace,
    recallTargetExactScope,
    recallTargetIsNamespaceWide,

    -- * Migrating from the overloaded scope
    legacyRecallTarget,

    -- * How a recall call searches
    RecallStrategy (..),
    allRecallStrategies,
    recallStrategyText,
    parseRecallStrategy,

    -- * How many results it may return
    RecallLimit,
    mkRecallLimit,
    recallLimitInt,
    defaultRecallLimit,
    maxRecallLimit,

    -- * The request
    RecallQuery (..),
    mkRecallQuery,
  )
where

import Data.Aeson (Key, (.:), (.:?))
import Data.Aeson qualified as Aeson
import Data.Aeson.Types qualified as Aeson (Pair, Parser)
import Data.Text qualified as Text
import Kioku.Api.Scope
  ( MemoryScope (..),
    Namespace (..),
    ScopeKind (..),
    mkNamespace,
    mkScopeKind,
  )
import Kioku.Prelude

-- | The two things a recall call can search, kept apart by construction.
--
-- There is deliberately no third constructor and no \"unspecified\" case. A target that could be
-- absent would immediately grow a default, and the only sensible default for a search is the
-- widest one — which is how the overloaded scope became a hazard in the first place.
data RecallTarget
  = -- | Exactly this scope. For @'ScopeGlobal' ns@ that is the global bucket of @ns@: rows
    -- recorded with no entity scope. For @'ScopeEntity' ns kind ref@ it is that entity and no
    -- other.
    ExactScope !MemoryScope
  | -- | Every scope in this namespace: the global bucket and every entity scope under it. This
    -- is what a pre-'RecallTarget' recall did with a global scope.
    NamespaceWide !Namespace
  deriving stock (Eq, Show, Generic)

-- | The namespace a target searches. Every target names exactly one; a target that spanned
-- namespaces has never existed and is not being introduced here.
recallTargetNamespace :: RecallTarget -> Namespace
recallTargetNamespace = \case
  ExactScope (ScopeGlobal ns) -> ns
  ExactScope (ScopeEntity ns _ _) -> ns
  NamespaceWide ns -> ns

-- | The scope an exact target names, or 'Nothing' for a namespace-wide one.
recallTargetExactScope :: RecallTarget -> Maybe MemoryScope
recallTargetExactScope = \case
  ExactScope scope -> Just scope
  NamespaceWide _ -> Nothing

-- | Whether this target widens past a single scope. Worth a name of its own: this is the
-- predicate an audit log, a CLI confirmation, or a policy check actually wants to ask.
recallTargetIsNamespaceWide :: RecallTarget -> Bool
recallTargetIsNamespaceWide = \case
  ExactScope _ -> False
  NamespaceWide _ -> True

-- | The target a pre-'RecallTarget' caller was asking for, given the 'MemoryScope' it passed.
--
-- @
-- 'ScopeGlobal' ns        -> 'NamespaceWide' ns
-- 'ScopeEntity' ns k r    -> 'ExactScope' ('ScopeEntity' ns k r)
-- @
--
-- Use this exactly once per call site, while migrating, to keep the results you have today. Then
-- decide: if you wanted the global bucket rather than the whole namespace, the answer is
-- @'ExactScope' ('ScopeGlobal' ns)@, which this function deliberately never produces.
--
-- Mapping the global scope to 'NamespaceWide' rather than to an exact global bucket is the whole
-- point. The opposite mapping would compile, run, and silently return a small fraction of the
-- rows the caller used to get.
legacyRecallTarget :: MemoryScope -> RecallTarget
legacyRecallTarget = \case
  ScopeGlobal ns -> NamespaceWide ns
  scope@ScopeEntity {} -> ExactScope scope

-- | Which retrieval channels a recall call runs.
--
-- This lived in @Kioku.Recall@ until targets became explicit. It belongs beside them: a request
-- is a target, a query, a strategy and a bound, and all four are pure vocabulary that a host,
-- an HTTP service, or an SDK has to be able to name without depending on the runtime.
data RecallStrategy
  = -- | Full-text search only. Needs no embedding endpoint.
    Keyword
  | -- | Vector similarity only. Needs the query embedded.
    Embedding
  | -- | Both channels, fused by reciprocal rank. The default and what you almost always want.
    Hybrid
  deriving stock (Generic, Eq, Show, Enum, Bounded)

-- | Every strategy, in the order they are documented.
allRecallStrategies :: [RecallStrategy]
allRecallStrategies = [minBound .. maxBound]

-- | The wire and command-line spelling of a strategy. These three strings appear in
-- @kioku recall --strategy@ and in every request body, so changing one is a breaking change.
recallStrategyText :: RecallStrategy -> Text
recallStrategyText = \case
  Keyword -> "keyword"
  Embedding -> "embedding"
  Hybrid -> "hybrid"

parseRecallStrategy :: Text -> Either Text RecallStrategy
parseRecallStrategy = \case
  "keyword" -> Right Keyword
  "embedding" -> Right Embedding
  "hybrid" -> Right Hybrid
  other ->
    Left
      ( "unknown recall strategy: "
          <> other
          <> " (expected "
          <> Text.intercalate ", " (recallStrategyText <$> allRecallStrategies)
          <> ")"
      )

-- | How many hits a recall call may return: at least one, at most 'maxRecallLimit'.
--
-- A validated newtype rather than a bare 'Int' because the two ends mean different things and
-- both were previously unenforced at the library boundary. Zero or negative is not \"no limit\",
-- it is a caller bug that silently returns nothing; and an unbounded upper end invites a request
-- for a million rows that the database would have to plan for. The command line has enforced
-- @1-100@ since it was written — this puts the same rule where library callers meet it.
newtype RecallLimit = RecallLimit Int
  deriving stock (Eq, Ord, Show, Generic)

-- | The largest number of hits a single recall call may ask for.
--
-- 100 matches the range @kioku recall --limit@ has always accepted, and it is also the most a
-- request can produce: each channel contributes at most 50 candidates, so a fused result set
-- holds at most 100 distinct memories. Asking for more has never returned more.
maxRecallLimit :: Int
maxRecallLimit = 100

-- | What @kioku recall@ uses when no limit is given.
defaultRecallLimit :: RecallLimit
defaultRecallLimit = RecallLimit 8

mkRecallLimit :: Int -> Either Text RecallLimit
mkRecallLimit value
  | value < 1 = Left ("recall limit must be at least 1: " <> Text.pack (show value))
  | value > maxRecallLimit =
      Left
        ( "recall limit must be at most "
            <> Text.pack (show maxRecallLimit)
            <> ": "
            <> Text.pack (show value)
        )
  | otherwise = Right (RecallLimit value)

recallLimitInt :: RecallLimit -> Int
recallLimitInt (RecallLimit value) = value

-- | Everything a recall call needs except the space it runs in.
--
-- The memory space is deliberately absent. It comes from the
-- 'Kioku.Api.Access.MemoryAccessContext' passed to 'Kioku.Recall.recall', because a request is
-- something a caller composes and a space is something an authorization decision granted. Keeping
-- them in separate values means no code path can widen a target and a tenancy in one edit.
--
-- Every field is already validated by its own type, so the record constructor is safe to export:
-- a 'RecallTarget' cannot be ambiguous and a 'RecallLimit' cannot be zero. 'mkRecallQuery' is a
-- convenience for callers holding a plain 'Int'.
--
-- The query text is /not/ validated. It is user input on its way to
-- @websearch_to_tsquery@, which is total by design and never raises, and rejecting empty or
-- punctuation-only text here would break callers that rely on today's \"no matches\" answer.
data RecallQuery = RecallQuery
  { target :: !RecallTarget,
    query :: !Text,
    strategy :: !RecallStrategy,
    maxResults :: !RecallLimit
  }
  deriving stock (Eq, Show, Generic)

mkRecallQuery :: RecallTarget -> Text -> RecallStrategy -> Int -> Either Text RecallQuery
mkRecallQuery target query strategy limit = do
  maxResults <- mkRecallLimit limit
  Right RecallQuery {target, query, strategy, maxResults}

-- * Wire format

-- $
-- The encoding is hand-written rather than derived, and the discriminator is required. Three
-- meanings, three tags, no field whose /absence/ changes what the request means:
--
-- @
-- {"kind":"exact_global","namespace":"mori"}
-- {"kind":"exact_entity","namespace":"mori","scope_kind":"repo","scope_ref":"shinzui\/kikan"}
-- {"kind":"namespace_wide","namespace":"mori"}
-- @
--
-- A derived encoding would have spelled the two Haskell constructors instead, leaving the
-- exact-global and exact-entity cases separated only by whether @scope_kind@ was present — which
-- is the null-means-something representation this whole change exists to remove, moved from SQL
-- onto the wire.
--
-- Decoding is strict in both directions: an unknown @kind@ is an error, and a variant carrying a
-- field it has no meaning for ('exact_global' with a @scope_kind@, say) is an error too, rather
-- than a value with a silently ignored field.

instance ToJSON RecallTarget where
  toJSON = \case
    ExactScope (ScopeGlobal (Namespace ns)) ->
      Aeson.object [pair "kind" exactGlobalTag, pair "namespace" ns]
    ExactScope (ScopeEntity (Namespace ns) (ScopeKind kind) ref) ->
      Aeson.object
        [ pair "kind" exactEntityTag,
          pair "namespace" ns,
          pair "scope_kind" kind,
          pair "scope_ref" ref
        ]
    NamespaceWide (Namespace ns) ->
      Aeson.object [pair "kind" namespaceWideTag, pair "namespace" ns]

instance FromJSON RecallTarget where
  parseJSON = Aeson.withObject "RecallTarget" \o -> do
    kind <- o .: "kind"
    namespace <- parseValidated mkNamespace =<< o .: "namespace"
    scopeKind <- o .:? "scope_kind"
    scopeRef <- o .:? "scope_ref"
    case (kind :: Text, scopeKind, scopeRef) of
      (k, Nothing, Nothing)
        | k == exactGlobalTag -> pure (ExactScope (ScopeGlobal namespace))
        | k == namespaceWideTag -> pure (NamespaceWide namespace)
      (k, Just kindText, Just ref)
        | k == exactEntityTag -> do
            entityKind <- parseValidated mkScopeKind kindText
            pure (ExactScope (ScopeEntity namespace entityKind ref))
      (k, _, _)
        | k `elem` [exactGlobalTag, exactEntityTag, namespaceWideTag] ->
            fail
              ( "recall target "
                  <> show k
                  <> " must carry "
                  <> ( if k == exactEntityTag
                         then "both scope_kind and scope_ref"
                         else "neither scope_kind nor scope_ref"
                     )
              )
        | otherwise ->
            fail
              ( "unknown recall target kind: "
                  <> show k
                  <> " (expected exact_global, exact_entity or namespace_wide)"
              )

instance ToJSON RecallStrategy where
  toJSON = toJSON . recallStrategyText

instance FromJSON RecallStrategy where
  parseJSON = Aeson.withText "RecallStrategy" (parseValidated parseRecallStrategy)

instance ToJSON RecallLimit where
  toJSON = toJSON . recallLimitInt

-- | Decoding enforces the same bounds as 'mkRecallLimit'. A newtype validated only on the path
-- the host controls is validated only where nobody attacks it.
instance FromJSON RecallLimit where
  parseJSON value = parseValidated mkRecallLimit =<< parseJSON value

instance ToJSON RecallQuery where
  toJSON request =
    Aeson.object
      [ pair "target" request.target,
        pair "query" request.query,
        pair "strategy" request.strategy,
        pair "max_results" request.maxResults
      ]

instance FromJSON RecallQuery where
  parseJSON = Aeson.withObject "RecallQuery" \o ->
    RecallQuery
      <$> o .: "target"
      <*> o .: "query"
      <*> o .: "strategy"
      <*> o .: "max_results"

exactGlobalTag, exactEntityTag, namespaceWideTag :: Text
exactGlobalTag = "exact_global"
exactEntityTag = "exact_entity"
namespaceWideTag = "namespace_wide"

-- | Run one of this project's @Either Text@ validating constructors inside a parser, so a wire
-- value is held to exactly the rule a directly constructed one is.
parseValidated :: (a -> Either Text b) -> a -> Aeson.Parser b
parseValidated validate = either (fail . Text.unpack) pure . validate

-- | 'Kioku.Prelude' re-exports @Control.Lens@, which owns @(.=)@, so aeson's stays out of scope
-- and object fields are built through this instead.
pair :: (ToJSON v) => Key -> v -> Aeson.Pair
pair key value = (key, toJSON value)