kioku-core-0.4.0.0: test/Kioku/RecallCompatSpec.hs
-- Every test here is /about/ the deprecated entry point, so the deprecation is suppressed for
-- this module and nowhere else. A caller that has not migrated must still get the warning, and a
-- future deprecated use anywhere outside this file must still be visible.
{-# OPTIONS_GHC -Wno-deprecations #-}
-- | The compatibility promise, executed against a real database.
--
-- 'Kioku.Recall.legacyRecall' exists so that a caller holding a pre-'RecallTarget'
-- 'RecallRequest' keeps the rows it has today while the compiler tells it to migrate. That
-- promise is only worth anything if it is measured, because the mistake it guards against —
-- mapping a global scope to the exact global bucket instead of to the whole namespace — compiles,
-- runs, and returns a plausible subset of the right answer with no error at all.
--
-- So each case here runs the legacy request and the explicit request side by side over the same
-- seeded rows and asserts they return the same memory ids. Everything runs keyword-only under
-- 'VectorExtensionUnavailable', so no embedding endpoint is involved and the embedding model is
-- never forced.
module Kioku.RecallCompatSpec (tests) where
import Data.List (sort)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding (encodeUtf8)
import Effectful (Eff, IOE, (:>))
import Hasql.Transaction qualified as Tx
import Kioku.Api.Access (memorySpaceIdText)
import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..))
import Kioku.Api.Types (MemoryRecord (..))
import Kioku.App (AppEffects, runAppIO, withNoopAppEnv)
import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase)
import Kioku.Recall
( RecallError (..),
RecallHit (..),
RecallLimit,
RecallQuery (..),
RecallRequest (..),
RecallStrategy (..),
RecallTarget (..),
legacyRecall,
mkRecallLimit,
recall,
)
import Kioku.Recall.Capability (VectorCapability (..))
import Kioku.SpaceFixtures (otherSpace, testContext, testSpace)
import Kiroku.Store.Connection (defaultConnectionSettings)
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Transaction (runTransaction)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertEqual, assertFailure, testCase)
-- Each 'withRecallFixture' spins an ephemeral PostgreSQL cluster and applies every migration, so
-- the scenarios are grouped into two of them rather than one per assertion.
tests :: TestTree
tests =
testGroup
"Recall.Compat"
[ testCase "a legacy scope returns exactly what its explicit target returns" testLegacyMatchesExplicit,
testCase "the legacy request's edges are preserved" testLegacyEdges
]
-- | The case the whole compatibility layer exists for.
--
-- A legacy @ScopeGlobal ns@ returns every active row in @ns@, entity-scoped rows included, and an
-- entity scope returns only itself. If 'Kioku.Api.Recall.legacyRecallTarget' were ever
-- "corrected" to send a global scope to the exact global bucket, the first assertion returns
-- @["m_global"]@ instead of both ids — a silent narrowing, which is exactly the kind of failure
-- that reaches a downstream months later as "recall got worse".
testLegacyMatchesExplicit :: IO ()
testLegacyMatchesExplicit =
withRecallFixture \runEff -> do
result <- runEff do
seedCorpus
legacyWide <- runLegacy (legacyRequest ns1Global)
explicitWide <- runExplicit (explicitRequest (NamespaceWide ns1))
legacyExact <- runLegacy (legacyRequest ns1Entity)
explicitExact <- runExplicit (explicitRequest (ExactScope ns1Entity))
pure (legacyWide, explicitWide, legacyExact, explicitExact)
case result of
Left err -> assertFailure ("store error: " <> show err)
Right (legacyWide, explicitWide, legacyExact, explicitExact) -> do
assertEqual
"a legacy global scope returns the global bucket and the entity scope alike"
(Right ["m_entity", "m_global"])
(hitIds legacyWide)
assertEqual
"and returns exactly what the explicit namespace-wide target returns"
(hitIds explicitWide)
(hitIds legacyWide)
assertEqual
"a legacy entity scope returns only that scope"
(Right ["m_entity"])
(hitIds legacyExact)
assertEqual
"and returns exactly what the explicit exact target returns"
(hitIds explicitExact)
(hitIds legacyExact)
-- | The three edges of the legacy request that 'RecallQuery' does not have.
--
-- __The space.__ The legacy request is the only recall input carrying a memory space of its own,
-- so it is the only one that can disagree with the context that authorized it. It is refused
-- rather than retargeted, for the same reason the deprecated write wrappers in "Kioku.Memory"
-- refuse: quietly rewriting the space would make the compatibility layer a way to reach another
-- space's data.
--
-- __A zero limit.__ @take (max 0 n)@ returned nothing, so this returns nothing, rather than
-- becoming a validation error an unmigrated caller meets as a crash.
--
-- __An oversized limit.__ 'Kioku.Api.Recall.mkRecallLimit' refuses anything above
-- 'Kioku.Api.Recall.maxRecallLimit', but a legacy caller passing 5000 was never getting 5000 rows
-- — each channel contributes at most 50 candidates — so clamping is invisible where refusing
-- would be a new failure.
testLegacyEdges :: IO ()
testLegacyEdges =
withRecallFixture \runEff -> do
result <- runEff do
seedCorpus
mismatched <- runLegacy (legacyRequest ns1Global) {memorySpaceId = otherSpace}
zeroLimit <- runLegacy (legacyRequestLimited ns1Global 0)
oversize <- runLegacy (legacyRequestLimited ns1Global 5000)
pure (mismatched, zeroLimit, oversize)
case result of
Left err -> assertFailure ("store error: " <> show err)
Right (mismatched, zeroLimit, oversize) -> do
case mismatched of
Left (RecallSpaceMismatch requested authorized) -> do
assertEqual "names the space that was asked for" otherSpace requested
assertEqual "names the space the context authorizes" testSpace authorized
other -> assertFailure ("expected a space mismatch, got " <> show (hitIds other))
assertEqual "a zero limit returns no hits" (Right []) (hitIds zeroLimit)
assertEqual
"an oversized limit still returns every match"
(Right ["m_entity", "m_global"])
(hitIds oversize)
runLegacy :: (Store :> es, IOE :> es) => RecallRequest -> Eff es (Either RecallError [RecallHit])
runLegacy = legacyRecall undefinedModel VectorExtensionUnavailable testContext
runExplicit :: (Store :> es, IOE :> es) => RecallQuery -> Eff es (Either RecallError [RecallHit])
runExplicit = recall undefinedModel VectorExtensionUnavailable testContext
-- * Fixture
ns1 :: Namespace
ns1 = Namespace "ns1"
ns1Global, ns1Entity, ns2Global :: MemoryScope
ns1Global = ScopeGlobal ns1
ns1Entity = ScopeEntity ns1 (ScopeKind "repo") "web"
ns2Global = ScopeGlobal (Namespace "ns2")
-- | The keyword channel never embeds, and 'VectorExtensionUnavailable' makes that a guarantee
-- rather than a hope: the execution plan is keyword-only, so this is never forced.
undefinedModel :: a
undefinedModel = error "the keyword channel must not embed"
legacyRequest :: MemoryScope -> RecallRequest
legacyRequest scope = legacyRequestLimited scope 10
-- | Built rather than record-updated: 'RecallRequest' and 'RecallQuery' both have a @maxResults@
-- field, and a record update over an ambiguous field is a warning GHC intends to stop supporting.
legacyRequestLimited :: MemoryScope -> Int -> RecallRequest
legacyRequestLimited scope maxResults =
RecallRequest
{ memorySpaceId = testSpace,
scope,
query = searchText,
strategy = Keyword,
maxResults
}
explicitRequest :: RecallTarget -> RecallQuery
explicitRequest target =
RecallQuery {target, query = searchText, strategy = Keyword, maxResults = limitOf 10}
searchText :: Text
searchText = "deployment pipeline"
limitOf :: Int -> RecallLimit
limitOf = either (error . Text.unpack) id . mkRecallLimit
hitIds :: Either RecallError [RecallHit] -> Either RecallError [Text]
hitIds = fmap (sort . fmap (\hit -> hit.memory.memoryId))
-- | One row in the global bucket, one under an entity scope in the same namespace, and one in a
-- different namespace that no target may ever return.
seedCorpus :: (Store :> es) => Eff es ()
seedCorpus =
seedMemories
[ ("m_global", ns1Global),
("m_entity", ns1Entity),
("m_other_ns", ns2Global)
]
seedMemories :: (Store :> es) => [(Text, MemoryScope)] -> Eff es ()
seedMemories rows =
runTransaction . Tx.sql . encodeUtf8 $
"INSERT INTO kioku.memories (memory_space_id, memory_id, agent_id, namespace, scope_kind, scope_ref, memory_type, content, status, created_at, updated_at) VALUES "
<> Text.intercalate ", " (row <$> rows)
where
row (memoryId, scope) =
"('"
<> memorySpaceIdText testSpace
<> "', '"
<> memoryId
<> "', 'agent', '"
<> namespaceOf scope
<> "', "
<> sqlText (kindOf scope)
<> ", "
<> sqlText (refOf scope)
<> ", 'fact', 'the deployment pipeline runs on nix flakes', 'active', now(), now())"
namespaceOf = \case
ScopeGlobal (Namespace ns) -> ns
ScopeEntity (Namespace ns) _ _ -> ns
kindOf = \case
ScopeGlobal _ -> Nothing
ScopeEntity _ (ScopeKind kind) _ -> Just kind
refOf = \case
ScopeGlobal _ -> Nothing
ScopeEntity _ _ ref -> Just ref
sqlText Nothing = "NULL"
sqlText (Just value) = "'" <> value <> "'"
withRecallFixture :: ((forall a. Eff AppEffects a -> IO (Either StoreError a)) -> IO ()) -> IO ()
withRecallFixture use =
withKiokuMigratedDatabase \connStr ->
withNoopAppEnv (defaultConnectionSettings connStr) \env ->
use (runAppIO env)