packages feed

kioku-core-0.10.0.0: test/Kioku/BoundedReadSpec.hs

module Kioku.BoundedReadSpec (tests) where

import Control.Concurrent.Async (concurrently)
import Control.Monad (replicateM)
import Data.List (sort)
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Time (addUTCTime)
import Effectful (Eff)
import Kioku.Api.Access (MemoryAccessContext, MemoryPermission (..), memoryContextRecordedActor, memoryContextSpace)
import Kioku.Api.Access.Internal qualified as Internal
import Kioku.Api.Recall (RecallTarget (..))
import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..))
import Kioku.Api.Types (Confidence (..), MemoryStatus (..), MemoryType (..))
import Kioku.App (AppEffects, runAppIO, withNoopAppEnv)
import Kioku.Id (MemoryId, SessionId, genMemoryId, genSessionId, idText)
import Kioku.Memory qualified as M
import Kioku.Memory.Domain (ArchiveMemoryData (..), RecordMemoryData (..), SupersedeMemoryData (..))
import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase)
import Kioku.Pagination (BoundedPageError (..), BoundedReadError (..))
import Kioku.Prelude
import Kioku.Session qualified as S
import Kioku.Session.Domain (RecordTurnData (..), StartSessionData (..))
import Kioku.SpaceFixtures (otherContext, testActor, testContext, testSpace)
import Kiroku.Store.Connection (defaultConnectionSettings)
import Relay.Pagination (Connection (..), Direction (..), Edge (..), PageInfo (..), PageRequest (..), PageRequestError (..))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertEqual, assertFailure, testCase)

tests :: TestTree
tests =
  testGroup
    "Bounded facade"
    [ testCase "session pages, scalar counts and subject filters respect space and explicit scope" (withApp sessionPages),
      testCase "memory pages filter before bounds and archive reports its own append" (withApp memoryPages),
      testCase "turn and continuation pages retain complete relation counts" (withApp relationPages),
      testCase "concurrent archives report exactly one appending command" archiveRace,
      testCase "supersession pages traverse both directions and stay in their space" (withApp supersessionPages),
      testCase "delegation children and started ranges select before bounds" (withApp delegationAndRange),
      testCase "memory type and session filters page in native session order" (withApp memoryFilters),
      testCase "every bounded read refuses a context without MemoryRead before SQL" (withApp readPermission)
    ]

ns :: Namespace
ns = Namespace "bounded"

entity :: MemoryScope
entity = ScopeEntity ns (ScopeKind "intention") "one"

right :: (Show e) => Either e a -> Eff AppEffects a
right = either (liftIO . assertFailure . show) pure

check :: (Eq a, Show a) => String -> a -> a -> Eff AppEffects ()
check label expected actual = liftIO (assertEqual label expected actual)

withApp :: Eff AppEffects a -> IO a
withApp action = withKiokuMigratedDatabase \conn -> withNoopAppEnv (defaultConnectionSettings conn) \env -> do
  result <- runAppIO env action
  either (assertFailure . show) pure result

start :: MemoryAccessContext -> MemoryScope -> UTCTime -> Maybe SessionId -> Eff AppEffects SessionId
start context scope now previous = startSession context scope now previous Nothing

startSession :: MemoryAccessContext -> MemoryScope -> UTCTime -> Maybe SessionId -> Maybe SessionId -> Eff AppEffects SessionId
startSession context scope now previous parent = do
  sid <- liftIO genSessionId
  right
    =<< S.startWithContext
      context
      StartSessionData
        { sessionId = sid,
          memorySpaceId = memoryContextSpace context,
          actorPrincipal = memoryContextRecordedActor context,
          ownerPrincipal = Nothing,
          agentId = "bounded",
          focus = "review",
          scope,
          subjectRef = Just "same-subject",
          previousSessionId = previous,
          parentSessionId = parent,
          delegationDepth = maybe 0 (const 1) parent,
          startedAt = now
        }

record :: MemoryAccessContext -> MemoryScope -> UTCTime -> Eff AppEffects MemoryId
record context scope now = recordWithPriority context scope now 100

recordWithPriority :: MemoryAccessContext -> MemoryScope -> UTCTime -> Int -> Eff AppEffects MemoryId
recordWithPriority context scope now priority = recordMemory context scope now priority Nothing MemoryFact

recordMemory :: MemoryAccessContext -> MemoryScope -> UTCTime -> Int -> Maybe SessionId -> MemoryType -> Eff AppEffects MemoryId
recordMemory context scope now priority session memoryType = do
  mid <- liftIO genMemoryId
  right
    =<< M.recordWithContext
      context
      RecordMemoryData
        { memoryId = mid,
          memorySpaceId = memoryContextSpace context,
          actorPrincipal = memoryContextRecordedActor context,
          ownerPrincipal = Nothing,
          agentId = "bounded",
          sessionId = session,
          scope,
          memoryType,
          content = "content",
          priority,
          confidence = HighConfidence,
          tags = Set.empty,
          supersedes = Nothing,
          recordedAt = now
        }

page :: Int -> PageRequest
page n = PageRequest n Forward Nothing

nodes :: Connection a -> [a]
nodes connection = map (.node) connection.edges

pageError :: Either BoundedReadError a -> Maybe BoundedPageError
pageError = \case
  Left (BoundedReadInvalidPage err) -> Just err
  _ -> Nothing

sessionPages :: Eff AppEffects ()
sessionPages = do
  now <- liftIO getCurrentTime
  ids <- replicateM 7 (start testContext entity now Nothing)
  global <- start testContext (ScopeGlobal ns) now Nothing
  _ <- start otherContext entity now Nothing
  let filters = S.SessionFilter (NamespaceWide ns) Nothing Nothing Nothing Nothing Nothing
      selection = S.FilteredSessions filters
      get req = right =<< S.getPageWithContext testContext selection req
  first <- get (page 3)
  second <- get (PageRequest 3 Forward first.pageInfo.endCursor)
  third <- get (PageRequest 3 Forward second.pageInfo.endCursor)
  check "all tied rows" (sort (map idText (global : ids))) (sort (map (.sessionId) (nodes first <> nodes second <> nodes third)))
  back <- get (PageRequest 3 Backward second.pageInfo.startCursor)
  check "backward order" (map (.sessionId) (nodes first)) (map (.sessionId) (nodes back))
  counts <- right =<< S.getCountsWithContext testContext selection
  check "complete count" 8 counts.total
  exact <- right =<< S.getPageWithContext testContext (S.FilteredSessions (filters {S.target = ExactScope (ScopeGlobal ns)})) (page 3)
  check "exact global is narrow" [idText global] (map (.sessionId) (nodes exact))
  subject <- right =<< S.getCountsWithContext testContext (S.FilteredSessions (S.SessionFilter (NamespaceWide ns) Nothing Nothing (Just "absent") Nothing Nothing))
  check "subject filter" 0 subject.total
  tooLarge <- S.getPageWithContext testContext selection (page 101)
  check "maximum enforced" (Just (InvalidPageSize (PageSizeTooLarge 101 100))) (pageError tooLarge)
  negative <- S.getPageWithContext testContext selection (page (-1))
  check "negative refused" (Just (InvalidPageSize (NegativePageSize (-1)))) (pageError negative)
  running <- right =<< S.getCountsWithContext testContext (S.FilteredSessions (S.SessionFilter (NamespaceWide ns) Nothing (Just S.StatusRunning) Nothing Nothing Nothing))
  check "typed status filter" 8 running.total
  completed <- right =<< S.getCountsWithContext testContext (S.FilteredSessions (S.SessionFilter (NamespaceWide ns) Nothing (Just S.StatusCompleted) Nothing Nothing Nothing))
  check "absent status" 0 completed.total

memoryPages :: Eff AppEffects ()
memoryPages = do
  now <- liftIO getCurrentTime
  ids <- replicateM 7 (record testContext entity now)
  global <- recordWithPriority testContext (ScopeGlobal ns) now 1
  _ <- record otherContext entity now
  let filters = M.MemoryFilter (ExactScope entity) (Just MemoryActive) Nothing Nothing
      selection = M.FilteredMemories filters
      get req = right =<< M.getPageWithContext testContext selection req
  first <- get (page 3)
  second <- get (PageRequest 3 Forward first.pageInfo.endCursor)
  third <- get (PageRequest 3 Forward second.pageInfo.endCursor)
  check "all tied rows" (sort (map idText ids)) (sort (map (.memoryId) (nodes first <> nodes second <> nodes third)))
  back <- get (PageRequest 3 Backward second.pageInfo.startCursor)
  check "backward order" (map (.memoryId) (nodes first)) (map (.memoryId) (nodes back))
  prioritized <- right =<< M.getPageWithContext testContext (M.FilteredMemories (filters {M.target = NamespaceWide ns})) (page 1)
  check "native priority order" [idText global] (map (.memoryId) (nodes prioritized))
  let archive = ArchiveMemoryData {memoryId = global, memorySpaceId = memoryContextSpace testContext, actorPrincipal = memoryContextRecordedActor testContext, archivedAt = now}
  initial <- right =<< M.archiveWithContextChanged testContext archive
  duplicate <- right =<< M.archiveWithContextChanged testContext archive
  check "archive changed" (global, True) initial
  check "archive repeated" (global, False) duplicate
  counts <- right =<< M.getCountsWithContext testContext (M.FilteredMemories (filters {M.target = NamespaceWide ns, M.status = Nothing}))
  check "all statuses count" (8, 7, 1) (counts.total, counts.active, counts.archived)
  archived <- right =<< M.getPageWithContext testContext (M.FilteredMemories (filters {M.target = NamespaceWide ns, M.status = Just MemoryArchived})) (page 1)
  check "status before bounds" [idText global] (map (.memoryId) (nodes archived))

relationPages :: Eff AppEffects ()
relationPages = do
  now <- liftIO getCurrentTime
  root <- start testContext entity now Nothing
  next <- start testContext entity now (Just root)
  latest <- start testContext entity now (Just next)
  forM_ [1 .. 7] \i -> do
    _ <-
      right
        =<< S.recordTurnWithContext
          testContext
          RecordTurnData
            { sessionId = latest,
              memorySpaceId = memoryContextSpace testContext,
              actorPrincipal = memoryContextRecordedActor testContext,
              turnId = "bounded-" <> Text.pack (show i),
              turnIndex = i,
              role = "user",
              content = "content",
              toolSummary = Nothing,
              promptTokens = Nothing,
              outputTokens = Nothing,
              recordedAt = now
            }
    pure ()
  relations <- right =<< S.getRelationsWithContext testContext latest
  check "complete counts" (True, 7, 3, 0) (relations.found, relations.turns, relations.chainMembers, relations.delegationChildren)
  turns <- right =<< S.getTurnsPageWithContext testContext latest (page 2)
  rest <- right =<< S.getTurnsPageWithContext testContext latest (PageRequest 100 Forward turns.pageInfo.endCursor)
  check "all turns" [1 .. 7] (map (.turnIndex) (nodes turns <> nodes rest))
  chain <- right =<< S.getPageWithContext testContext (S.ContinuationChain (idText latest)) (page 100)
  check "all ancestors" (sort (map idText [root, next, latest])) (sort (map (.sessionId) (nodes chain)))
  hidden <- right =<< S.getRelationsWithContext otherContext latest
  check "foreign relation invisible" (False, 0, 0, 0) (hidden.found, hidden.turns, hidden.chainMembers, hidden.delegationChildren)

archiveRace :: IO ()
archiveRace = withKiokuMigratedDatabase \conn -> withNoopAppEnv (defaultConnectionSettings conn) \env -> do
  let run :: Eff AppEffects a -> IO a
      run action = runAppIO env action >>= either (assertFailure . show) pure
  now <- getCurrentTime
  mid <- run (record testContext entity now)
  let command =
        M.archiveWithContextChanged
          testContext
          ArchiveMemoryData
            { memoryId = mid,
              memorySpaceId = memoryContextSpace testContext,
              actorPrincipal = memoryContextRecordedActor testContext,
              archivedAt = now
            }
  (a, b) <- concurrently (run (right =<< command)) (run (right =<< command))
  assertEqual "one authoritative append" [False, True] (sort [snd a, snd b])

supersessionPages :: Eff AppEffects ()
supersessionPages = do
  now <- liftIO getCurrentTime
  a <- record testContext entity now
  b <- record testContext entity now
  c <- record testContext entity now
  _ <-
    right
      =<< M.supersedeWithContext
        testContext
        SupersedeMemoryData
          { memoryId = a,
            memorySpaceId = memoryContextSpace testContext,
            actorPrincipal = memoryContextRecordedActor testContext,
            supersededBy = b,
            supersededAt = now
          }
  _ <-
    right
      =<< M.supersedeWithContext
        testContext
        SupersedeMemoryData
          { memoryId = b,
            memorySpaceId = memoryContextSpace testContext,
            actorPrincipal = memoryContextRecordedActor testContext,
            supersededBy = c,
            supersededAt = now
          }
  let selection = M.SupersessionChain (idText b)
      get req = right =<< M.getPageWithContext testContext selection req
  first <- get (page 1)
  rest <- get (PageRequest 100 Forward first.pageInfo.endCursor)
  check "both directions" (sort (map idText [a, b, c])) (sort (map (.memoryId) (nodes first <> nodes rest)))
  counts <- right =<< M.getCountsWithContext testContext selection
  check "complete chain counts" (3, 1, 2) (counts.total, counts.active, counts.superseded)
  foreignPage <- right =<< M.getPageWithContext otherContext selection (page 100)
  check "foreign chain invisible" [] (map (.memoryId) (nodes foreignPage))

delegationAndRange :: Eff AppEffects ()
delegationAndRange = do
  now <- liftIO getCurrentTime
  parent <- start testContext entity now Nothing
  delegated <- forM [1 .. 4] \i -> startSession testContext entity (addUTCTime (fromInteger i) now) Nothing (Just parent)
  _ <- startSession otherContext entity now Nothing Nothing
  let get req = right =<< S.getPageWithContext testContext (S.DelegationChildren (idText parent)) req
  first <- get (page 3)
  rest <- get (PageRequest 3 Forward first.pageInfo.endCursor)
  check "children ascend by start" (map idText delegated) (map (.sessionId) (nodes first <> nodes rest))
  relations <- right =<< S.getRelationsWithContext testContext parent
  check "child count" 4 relations.delegationChildren
  let filters = S.SessionFilter (ExactScope entity) Nothing Nothing Nothing (Just (addUTCTime 2 now)) (Just (addUTCTime 4 now))
  ranged <- right =<< S.getPageWithContext testContext (S.FilteredSessions filters) (page 100)
  check "half-open started range" (map idText (reverse (take 2 (drop 1 delegated)))) (map (.sessionId) (nodes ranged))
  rangedCount <- right =<< S.getCountsWithContext testContext (S.FilteredSessions filters)
  check "range count" 2 rangedCount.total

memoryFilters :: Eff AppEffects ()
memoryFilters = do
  now <- liftIO getCurrentTime
  sid <- start testContext entity now Nothing
  inSession <- forM [1 .. 5] \i -> recordMemory testContext entity (addUTCTime (fromInteger i) now) (fromInteger i) (Just sid) MemoryFact
  preference <- recordMemory testContext entity now 100 Nothing MemoryPreference
  _ <- recordMemory testContext entity now 100 Nothing MemoryFact
  let filters = M.MemoryFilter (NamespaceWide ns) Nothing Nothing (Just (idText sid))
      get req = right =<< M.getPageWithContext testContext (M.FilteredMemories filters) req
  first <- get (page 2)
  second <- get (PageRequest 2 Forward first.pageInfo.endCursor)
  third <- get (PageRequest 2 Forward second.pageInfo.endCursor)
  check "session order ignores priority" (map idText (reverse inSession)) (map (.memoryId) (nodes first <> nodes second <> nodes third))
  back <- get (PageRequest 2 Backward second.pageInfo.startCursor)
  check "session backward order" (map (.memoryId) (nodes first)) (map (.memoryId) (nodes back))
  typed <- right =<< M.getPageWithContext testContext (M.FilteredMemories (M.MemoryFilter (NamespaceWide ns) Nothing (Just MemoryPreference) Nothing)) (page 100)
  check "memory type filter" [idText preference] (map (.memoryId) (nodes typed))

readPermission :: Eff AppEffects ()
readPermission = do
  now <- liftIO getCurrentTime
  let writer = Internal.MemoryAccessContext {Internal.memorySpaceId = testSpace, Internal.actor = testActor, Internal.grantedPermissions = Set.fromList [MemoryRecord, MemoryForget], Internal.decisionToken = Nothing}
      denied :: Either BoundedReadError a -> Bool
      denied = \case
        Left (BoundedReadNotPermitted MemoryRead) -> True
        _ -> False
  sid <- start writer entity now Nothing
  _ <- record writer entity now
  sessionPage <- S.getPageWithContext writer (S.FilteredSessions (S.SessionFilter (NamespaceWide ns) Nothing Nothing Nothing Nothing Nothing)) (page 1)
  sessionCounts <- S.getCountsWithContext writer (S.ContinuationChain (idText sid))
  turnsPage <- S.getTurnsPageWithContext writer sid (page 1)
  relations <- S.getRelationsWithContext writer sid
  memoryPage <- M.getPageWithContext writer (M.FilteredMemories (M.MemoryFilter (NamespaceWide ns) Nothing Nothing Nothing)) (page 1)
  memoryCounts <- M.getCountsWithContext writer (M.SupersessionChain "absent")
  check "all six reads refused" [True, True, True, True, True, True] [denied sessionPage, denied sessionCounts, denied turnsPage, denied relations, denied memoryPage, denied memoryCounts]
  -- The gate runs before page validation, so an invalid page cannot probe a refused space.
  invalid <- S.getPageWithContext writer (S.DelegationChildren (idText sid)) (page 101)
  check "permission before page size" True (denied invalid)