exchangealgebra-0.5.0.0: src/ExchangeAlgebra/Simulate/Spill.hs
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE StrictData #-}
{- |
Module : ExchangeAlgebra.Simulate.Spill
Copyright : (c) Kaya Akagi. 2018-2026
Maintainer : yakagika@icloud.com
Description : Spill-to-disk codec and restore for long simulations: chunk writers, checked readers, and ledger restoration. Shared by the classic engine ("ExchangeAlgebra.Simulate"), the Lite runner and the ledger policy layer; depends only on Algebra and Journal.
Released under the OWL license
-}
module ExchangeAlgebra.Simulate.Spill
( SpillOptions(..)
, SpillDeletePolicy(..)
, stepBackWith
, spillDeleteDecision
, mkSpillOptions
, mkBinarySpillOptions
, defaultSpillWriter
, defaultBinarySpillWriter
, SpillReadError(..)
, SpillRangeIssue(..)
, renderSpillReadError
, readBinarySpillFile
, readBinarySpillFileChecked
, restoreJournalFromBinarySpill
, restoreJournalFromBinarySpillChecked
) where
import Control.Monad.ST (RealWorld, ST)
import qualified Data.Binary as Binary
import qualified Data.ByteString.Lazy as BL
import Data.Int (Int64)
import qualified Data.List as L
import System.IO (Handle, hPutStr, hPutStrLn)
import ExchangeAlgebra.Algebra ((.+), HatBaseClass, HatVal)
import qualified ExchangeAlgebra.Journal as EJ
-- | Spill configuration for periodic external logging.
-- `spillExtract` selects accounting payload from world.
-- `spillWriteChunk` controls on-disk format.
data SpillOptions t a payload = SpillOptions
{ spillEveryTerms :: !Int
, spillFilePath :: FilePath
, spillExtract :: a RealWorld -> ST RealWorld payload
, spillExtractChunk :: Maybe ((t, t) -> a RealWorld -> ST RealWorld payload)
, spillWriteChunk :: Handle -> (t, t) -> payload -> IO ()
, spillDeletePolicy :: SpillDeletePolicy t
, spillDeleteRange :: (t, t) -> a RealWorld -> ST RealWorld ()
}
-- | Policy to decide which term range to evict after each spill.
data SpillDeletePolicy t
= NoDelete
| DeleteSpilledChunk
| KeepRecentTerms Int
-- | Step a term back @n@ times with the supplied step function (the classic
-- engine's previous-term operation, or 'pred' in "ExchangeAlgebra.Simulate.Lite"'s
-- retention loop). This is the __single definition__ of the eviction-window
-- arithmetic that was previously duplicated in both engines (design-review
-- C4). @n <= 0@ returns the term unchanged.
--
-- Complexity: O(n)
{-# INLINE stepBackWith #-}
stepBackWith :: (t -> t) -> Int -> t -> t
stepBackWith step = go
where
go n x | n <= 0 = x
| otherwise = go (n - 1) (step x)
-- | The per-chunk delete decision, as a pure function of the
-- 'SpillDeletePolicy' -- the single source of "which term range is evicted
-- after a chunk @(chunkStart, chunkEnd)@ is spilled" (design-review C4):
--
-- * 'NoDelete' -- evict nothing.
-- * 'DeleteSpilledChunk' -- evict exactly the spilled chunk.
-- * @'KeepRecentTerms' n@ -- evict the chunk except the trailing @n@ terms
-- (the resident window); evict nothing when the window covers the chunk.
--
-- The step function abstracts the engine's notion of "previous term"
-- (the classic engine's previous-term operation;
-- "ExchangeAlgebra.Simulate.Lite" uses 'pred').
--
-- Complexity: O(n) for @'KeepRecentTerms' n@, O(1) otherwise.
spillDeleteDecision :: Ord t => (t -> t) -> SpillDeletePolicy t -> (t, t) -> Maybe (t, t)
spillDeleteDecision step policy (chunkStart, chunkEnd) = case policy of
NoDelete -> Nothing
DeleteSpilledChunk -> Just (chunkStart, chunkEnd)
KeepRecentTerms keepN ->
let deleteEnd = stepBackWith step keepN chunkEnd
in if deleteEnd < chunkStart
then Nothing
else Just (chunkStart, deleteEnd)
-- | Construct text-format SpillOptions.
-- interval is the spill interval (in terms), path is the output file path.
--
-- Complexity: O(1)
mkSpillOptions :: Show t
=> Int
-> FilePath
-> (a RealWorld -> ST RealWorld String)
-> SpillOptions t a String
mkSpillOptions interval path extractF =
SpillOptions
{ spillEveryTerms = max 1 interval
, spillFilePath = path
, spillExtract = extractF
, spillExtractChunk = Nothing
, spillWriteChunk = defaultSpillWriter
, spillDeletePolicy = NoDelete
, spillDeleteRange = \_ _ -> pure ()
}
-- | Construct binary-format SpillOptions.
-- Spills in a format that can be restored with 'readBinarySpillFile'.
--
-- Complexity: O(1)
mkBinarySpillOptions :: (Binary.Binary t, Binary.Binary payload)
=> Int
-> FilePath
-> (a RealWorld -> ST RealWorld payload)
-> SpillOptions t a payload
mkBinarySpillOptions interval path extractF =
SpillOptions
{ spillEveryTerms = max 1 interval
, spillFilePath = path
, spillExtract = extractF
, spillExtractChunk = Nothing
, spillWriteChunk = defaultBinarySpillWriter
, spillDeletePolicy = NoDelete
, spillDeleteRange = \_ _ -> pure ()
}
-- | Default text-format spill writer.
-- Writes the chunk range and payload as text to the handle.
defaultSpillWriter :: Show t => Handle -> (t, t) -> String -> IO ()
defaultSpillWriter h (tStart, tEnd) payload = do
hPutStrLn h ("# chunk " ++ show tStart ++ " " ++ show tEnd)
hPutStr h payload
hPutStrLn h "\n# end-chunk"
-- | Default binary-format spill writer.
-- Writes the chunk range and payload to the handle using 'Binary.encode'.
defaultBinarySpillWriter :: (Binary.Binary t, Binary.Binary payload)
=> Handle -> (t, t) -> payload -> IO ()
defaultBinarySpillWriter h termRange payload =
BL.hPut h $ Binary.encode (termRange, payload)
-- | Why a binary spill file could not be read back as a well-formed chunk sequence.
data SpillReadError t
= SpillDecodeFailure
{ spillErrorOffset :: !Int64 -- ^ Byte offset at which decoding failed.
, spillErrorChunks :: !Int -- ^ Chunks decoded successfully before the failure.
, spillErrorMessage :: String -- ^ Message from 'Binary.decodeOrFail'.
}
| SpillRangeError
{ spillRangeIssue :: !SpillRangeIssue
, spillRangePrevious :: (t, t) -- ^ The chunk range decoded just before.
, spillRangeCurrent :: (t, t) -- ^ The offending chunk range.
}
| SpillEmptyRange
{ spillRangeCurrent :: (t, t) -- ^ A chunk whose start is after its end.
}
deriving (Eq, Show)
-- | The relationship by which a chunk range fails to follow its predecessor.
data SpillRangeIssue
= ChunkOutOfOrder
| ChunkOverlap
| ChunkGap
deriving (Eq, Show, Enum, Bounded)
-- | Render a spill read error for an exception or diagnostic message.
renderSpillReadError :: Show t => SpillReadError t -> String
renderSpillReadError err = case err of
SpillDecodeFailure offset chunks message ->
"binary spill decode failure at byte offset " ++ show offset
++ " after " ++ show chunks ++ " chunks: " ++ message
SpillRangeError issue previous current ->
"binary spill range error (" ++ show issue ++ "): chunk "
++ show current ++ " follows " ++ show previous
SpillEmptyRange current ->
"binary spill empty range: " ++ show current
-- | Read a binary spill file and return it as a list of chunks.
-- Used to restore files written by 'defaultBinarySpillWriter'.
-- Raises an error at the first undecodable chunk; no partial result is returned.
--
-- Complexity: O(file size)
readBinarySpillFile :: (Binary.Binary t, Binary.Binary payload)
=> FilePath
-> IO [((t, t), payload)]
readBinarySpillFile path = do
bytes <- BL.readFile path
case decodeBinarySpillChunks bytes of
Left (offset, chunks, message) ->
let err = SpillDecodeFailure offset chunks message :: SpillReadError ()
in error (renderSpillReadError err)
Right chunks -> pure chunks
-- | Read and validate every chunk in a binary spill file.
--
-- In addition to decode failures, this rejects empty, overlapping,
-- out-of-order, and gapped ranges. Gaps are errors because restoring around a
-- gap would discard the corresponding terms from the in-memory remainder and
-- silently turn missing spill data into an apparently complete ledger.
-- An empty file is a valid spill containing no chunks.
--
-- Complexity: O(file size + number of chunks)
readBinarySpillFileChecked
:: (Binary.Binary t, Binary.Binary payload, Ord t, Enum t)
=> FilePath
-> IO (Either (SpillReadError t) [((t, t), payload)])
readBinarySpillFileChecked path = do
bytes <- BL.readFile path
pure $ case decodeBinarySpillChunks bytes of
Left (offset, chunks, message) ->
Left (SpillDecodeFailure offset chunks message)
Right chunks -> validateChunkRanges chunks
-- Decode once for both public readers. The offset from 'Binary.decodeOrFail' is
-- relative to the current suffix, so add the bytes consumed by prior chunks.
decodeBinarySpillChunks
:: (Binary.Binary t, Binary.Binary payload)
=> BL.ByteString
-> Either (Int64, Int, String) [((t, t), payload)]
decodeBinarySpillChunks = go 0 0
where
go _ _ bs | BL.null bs = Right []
go offset decoded bs = case Binary.decodeOrFail bs of
Left (_, localOffset, message) ->
Left (offset + localOffset, decoded, message)
Right (rest, consumed, entry) ->
(entry :) <$> go (offset + consumed) (decoded + 1) rest
validateChunkRanges
:: (Ord t, Enum t)
=> [((t, t), payload)]
-> Either (SpillReadError t) [((t, t), payload)]
validateChunkRanges chunks = go Nothing chunks >> Right chunks
where
go _ [] = Right ()
go previous (((s, e), _) : rest)
| s > e = Left (SpillEmptyRange (s, e))
| otherwise = case previous of
Nothing -> go (Just (s, e)) rest
Just prior@(ps, pe)
| s <= pe && e >= ps ->
Left (SpillRangeError ChunkOverlap prior (s, e))
| e < ps ->
Left (SpillRangeError ChunkOutOfOrder prior (s, e))
| s /= succ pe ->
Left (SpillRangeError ChunkGap prior (s, e))
| otherwise -> go (Just (s, e)) rest
-- | Restore a complete Journal from spilled binary chunks and the current in-memory Journal.
-- The in-memory portion is narrowed to only terms after the last spill range,
-- so duplicate terms are not double-counted.
-- A malformed or stale spill file is not restored: this function raises an
-- error instead. Use 'restoreJournalFromBinarySpillChecked' when the caller
-- needs the failure represented as 'Either'.
--
-- Complexity: O(file size + number of chunks * union cost)
restoreJournalFromBinarySpill
:: ( Binary.Binary t
, Ord t
, Enum t
, Show t
, Binary.Binary (EJ.Journal n v b)
, EJ.Note n
, HatVal v
, HatBaseClass b
)
=> FilePath
-> (n -> t)
-> EJ.Journal n v b
-> IO (EJ.Journal n v b)
restoreJournalFromBinarySpill spillPath noteToTerm currentLedger = do
restored <- restoreJournalFromBinarySpillChecked
spillPath noteToTerm currentLedger
case restored of
Left err -> error (renderSpillReadError err)
Right ledger -> pure ledger
-- | Checked form of 'restoreJournalFromBinarySpill'.
-- The current ledger is merged only after the entire spill file has decoded
-- and its chunk ranges have passed the continuity checks.
--
-- Complexity: O(file size + number of chunks * union cost)
restoreJournalFromBinarySpillChecked
:: ( Binary.Binary t
, Ord t
, Enum t
, Binary.Binary (EJ.Journal n v b)
, EJ.Note n
, HatVal v
, HatBaseClass b
)
=> FilePath
-> (n -> t)
-> EJ.Journal n v b
-> IO (Either (SpillReadError t) (EJ.Journal n v b))
restoreJournalFromBinarySpillChecked spillPath noteToTerm currentLedger = do
result <- readBinarySpillFileChecked spillPath
pure $ fmap restore result
where
restore chunks =
let spilled = L.foldl' (\acc (_, j) -> acc .+ j) mempty chunks
latestEnd = L.foldl'
(\acc ((_, tEnd), _) ->
case acc of
Nothing -> Just tEnd
Just x -> Just (max x tEnd)
)
Nothing
chunks
remainder = case latestEnd of
Nothing -> currentLedger
Just tEnd ->
EJ.filterWithNote (\n _ -> noteToTerm n > tEnd) currentLedger
in spilled .+ remainder