diff --git a/lib/core/src/Cardano/Wallet/Primitive/Types.hs b/lib/core/src/Cardano/Wallet/Primitive/Types.hs index 9b92a0d3a12..e70397c1bf1 100644 --- a/lib/core/src/Cardano/Wallet/Primitive/Types.hs +++ b/lib/core/src/Cardano/Wallet/Primitive/Types.hs @@ -89,6 +89,11 @@ module Cardano.Wallet.Primitive.Types , SlotNo (..) , EpochNo (..) , unsafeEpochNo + , epochStartTime + , epochPred + , epochSucc + , epochCeiling + , epochFloor , SlotParameters (..) , SlotLength (..) , EpochLength (..) @@ -1095,6 +1100,54 @@ unsafeEpochNo epochNo maxEpochNo :: Word32 maxEpochNo = fromIntegral @Word31 $ unEpochNo maxBound +-- | Calculate the time at which an epoch begins. +epochStartTime :: SlotParameters -> EpochNo -> UTCTime +epochStartTime sps e = slotStartTime sps $ SlotId e 0 + +-- | Return the epoch immediately before the given epoch, or 'Nothing' if there +-- is no representable epoch before the given epoch. +epochPred :: EpochNo -> Maybe EpochNo +epochPred (EpochNo e) + | e == minBound = Nothing + | otherwise = Just $ EpochNo $ pred e + +-- | Return the epoch immediately after the given epoch, or 'Nothing' if there +-- is no representable epoch after the given epoch. +epochSucc :: EpochNo -> Maybe EpochNo +epochSucc (EpochNo e) + | e == maxBound = Nothing + | otherwise = Just $ EpochNo $ succ e + +-- | For the given time 't', calculate the number of the earliest epoch with +-- start time 's' such that 't ≤ s'. +-- +-- Returns 'Nothing' if the calculation would result in an epoch number that is +-- not representable. +epochCeiling :: SlotParameters -> UTCTime -> Maybe EpochNo +epochCeiling sps t + | t < timeMin = Just minBound + | t > timeMax = Nothing + | otherwise = case slotCeiling sps t of + SlotId epoch 0 -> Just epoch + SlotId epoch _ -> epochSucc epoch + where + timeMin = epochStartTime sps minBound + timeMax = epochStartTime sps maxBound + +-- | For the given time 't', calculate the number of the latest epoch with +-- start time 's' such that 's ≤ t'. +-- +-- Returns 'Nothing' if the calculation would result in an epoch number that is +-- not representable. +epochFloor :: SlotParameters -> UTCTime -> Maybe EpochNo +epochFloor sps t + | t < timeMin = Nothing + | t > timeMax = Just maxBound + | otherwise = epochNumber <$> slotFloor sps t + where + timeMin = epochStartTime sps minBound + timeMax = epochStartTime sps maxBound + instance NFData SlotId instance Buildable SlotId where diff --git a/lib/core/test/unit/Cardano/Wallet/Primitive/TypesSpec.hs b/lib/core/test/unit/Cardano/Wallet/Primitive/TypesSpec.hs index 66e7de18c9f..810ab49b20b 100644 --- a/lib/core/test/unit/Cardano/Wallet/Primitive/TypesSpec.hs +++ b/lib/core/test/unit/Cardano/Wallet/Primitive/TypesSpec.hs @@ -54,6 +54,11 @@ import Cardano.Wallet.Primitive.Types , WalletName (..) , balance , computeUtxoStatistics + , epochCeiling + , epochFloor + , epochPred + , epochStartTime + , epochSucc , excluding , flatSlot , fromFlatSlot @@ -121,7 +126,7 @@ import Data.Text import Data.Text.Class ( TextDecodingError (..), fromText, toText ) import Data.Time - ( UTCTime ) + ( Day (ModifiedJulianDay), UTCTime, toModifiedJulianDay, utctDay ) import Data.Time.Utils ( utcTimePred, utcTimeSucc ) import Data.Word @@ -145,6 +150,7 @@ import Test.QuickCheck , NonNegative (..) , NonZero (..) , Property + , Small (..) , arbitraryBoundedEnum , arbitraryPrintableChar , arbitrarySizedBoundedIntegral @@ -171,7 +177,7 @@ import Test.QuickCheck.Monadic import Test.Text.Roundtrip ( textRoundtrip ) import Test.Utils.Time - ( genUniformTime, getUniformTime ) + ( genUniformTime, genUniformTimeWithinRange, getUniformTime ) import qualified Data.ByteArray as BA import qualified Data.ByteString as BS @@ -413,6 +419,187 @@ spec = do property $ \(a :: Int) (b :: Int) -> compare (InclusiveBound a) (InclusiveBound b) === compare a b + describe "Epoch arithmetic: predecessors and successors" $ do + + let succN n = applyN n (epochSucc =<<) + let predN n = applyN n (epochPred =<<) + + it "epochPred minBound == Nothing" $ + epochPred minBound === Nothing + + it "epochSucc maxBound == Nothing" $ + epochSucc maxBound === Nothing + + it "(applyN n epochSucc) . (applyN n epochPred) == id" $ + withMaxSuccess 1000 $ property $ + \(Small epochWord) (Small n) -> + let epoch = EpochNo epochWord + withinBounds = minBound + n <= unEpochNo epoch + expectedResult = + if withinBounds then Just epoch else Nothing + in + checkCoverage $ + cover 10 withinBounds "within bounds" $ + cover 10 (not withinBounds) "out of bounds" $ + expectedResult === succN n (predN n $ Just epoch) + + it "(applyN n epochPred) . (applyN n epochSucc) == id" $ + withMaxSuccess 1000 $ property $ + \(Small epochWord) (Small n) -> + let epoch = EpochNo $ maxBound - epochWord + withinBounds = maxBound - n >= unEpochNo epoch + expectedResult = + if withinBounds then Just epoch else Nothing + in + checkCoverage $ + cover 10 withinBounds "within bounds" $ + cover 10 (not withinBounds) "out of bounds" $ + expectedResult === predN n (succN n $ Just epoch) + + describe "Epoch arithmetic: epochCeiling: core properties" $ do + + it "epochStartTime (epochCeiling t) >= t" $ + withMaxSuccess 1000 $ property $ + \(SlotParametersAndTimePoint sps time) -> do + let timeMaximum = epochStartTime sps maxBound + let withinBounds = time <= timeMaximum + checkCoverage $ + cover 10 withinBounds "within bounds" $ + cover 10 (not withinBounds) "out of bounds" $ + case epochCeiling sps time of + Nothing -> not withinBounds + Just en -> time <= epochStartTime sps en + + it "epochStartTime (epochPred (epochCeiling t)) < t" $ + withMaxSuccess 1000 $ property $ + \(SlotParametersAndTimePoint sps time) -> do + let timeMaximum = epochStartTime sps maxBound + let withinBounds = time <= timeMaximum + checkCoverage $ + cover 10 withinBounds "within bounds" $ + cover 10 (not withinBounds) "out of bounds" $ + case epochCeiling sps time of + Nothing -> not withinBounds + Just e1 -> case epochPred e1 of + Nothing -> e1 == minBound + Just e2 -> time > epochStartTime sps e2 + + describe "Epoch arithmetic: epochFloor: core properties" $ do + + it "epochStartTime (epochFloor t) <= t" $ + withMaxSuccess 1000 $ property $ + \(SlotParametersAndTimePoint sps time) -> do + let timeMinimum = epochStartTime sps minBound + let withinBounds = time >= timeMinimum + checkCoverage $ + cover 10 withinBounds "within bounds" $ + cover 10 (not withinBounds) "out of bounds" $ + case epochFloor sps time of + Nothing -> not withinBounds + Just en -> time >= epochStartTime sps en + + it "epochStartTime (epochSucc (epochFloor t)) > t" $ + withMaxSuccess 1000 $ property $ + \(SlotParametersAndTimePoint sps time) -> do + let timeMinimum = epochStartTime sps minBound + let withinBounds = time >= timeMinimum + checkCoverage $ + cover 10 withinBounds "within bounds" $ + cover 10 (not withinBounds) "out of bounds" $ + case epochFloor sps time of + Nothing -> not withinBounds + Just e1 -> case epochSucc e1 of + Nothing -> e1 == maxBound + Just e2 -> time < epochStartTime sps e2 + + describe "Epoch arithmetic: epochCeiling: boundary conditions" $ do + + it "epochCeiling . epochStartTime == id" $ + withMaxSuccess 1000 $ property $ \(sps, epoch) -> + epochCeiling sps (epochStartTime sps epoch) + === Just epoch + + it "epochCeiling . utcTimePred . epochStartTime == id" $ + withMaxSuccess 1000 $ property $ \(sps, epoch) -> + epoch > minBound ==> do + let fun = epochCeiling sps + . utcTimePred + . epochStartTime sps + Just epoch === fun epoch + + it "epochPred . epochCeiling . utcTimeSucc . epochStartTime == id" $ + withMaxSuccess 1000 $ property $ \(sps, epoch) -> + epoch < maxBound ==> do + let fun = (epochPred =<<) + . epochCeiling sps + . utcTimeSucc + . epochStartTime sps + Just epoch === fun epoch + + it "epochCeiling (epochStartTime minBound) == minBound" $ + withMaxSuccess 1000 $ property $ \sps -> + epochCeiling sps (epochStartTime sps minBound) + === Just minBound + + it "epochCeiling (utcTimePred (epochStartTime minBound)) == minBound" $ + withMaxSuccess 1000 $ property $ \sps -> + epochCeiling sps (utcTimePred (epochStartTime sps minBound)) + === Just minBound + + it "epochCeiling (epochStartTime maxBound) == maxBound" $ + withMaxSuccess 1000 $ property $ \sps -> + epochCeiling sps (epochStartTime sps maxBound) + === Just maxBound + + it "epochCeiling (utcTimeSucc (epochStartTime maxBound)) == Nothing" $ + withMaxSuccess 1000 $ property $ \sps -> + epochCeiling sps (utcTimeSucc (epochStartTime sps maxBound)) + === Nothing + + describe "Epoch arithmetic: epochFloor: boundary conditions" $ do + + it "epochFloor . epochStartTime == id" $ + withMaxSuccess 1000 $ property $ \(sps, epoch) -> + epochFloor sps (epochStartTime sps epoch) + === Just epoch + + it "epochFloor . utcTimeSucc . epochStartTime == id" $ + withMaxSuccess 1000 $ property $ \(sps, epoch) -> + epoch < maxBound ==> do + let fun = epochFloor sps + . utcTimeSucc + . epochStartTime sps + Just epoch === fun epoch + + it "epochSucc . epochFloor . utcTimePred . epochStartTime == id" $ + withMaxSuccess 1000 $ property $ \(sps, epoch) -> + epoch > minBound ==> do + let fun = (epochSucc =<<) + . epochFloor sps + . utcTimePred + . epochStartTime sps + Just epoch === fun epoch + + it "epochFloor (epochStartTime minBound) == minBound" $ + withMaxSuccess 1000 $ property $ \sps -> + epochFloor sps (epochStartTime sps minBound) + === Just minBound + + it "epochFloor (utcTimePred (epochStartTime minBound)) == Nothing" $ + withMaxSuccess 1000 $ property $ \sps -> + epochFloor sps (utcTimePred (epochStartTime sps minBound)) + === Nothing + + it "epochFloor (epochStartTime maxBound) == maxBound" $ + withMaxSuccess 1000 $ property $ \sps -> + epochFloor sps (epochStartTime sps maxBound) + === Just maxBound + + it "epochFloor (utcTimeSucc (epochStartTime maxBound)) == maxBound" $ + withMaxSuccess 1000 $ property $ \sps -> + epochFloor sps (utcTimeSucc (epochStartTime sps maxBound)) + === Just maxBound + describe "Slot arithmetic" $ do it "slotFloor (slotStartTime slotMinBound) == Just slotMinBound" $ @@ -1103,6 +1290,10 @@ instance Arbitrary BlockHeader where , pure $ Hash "BLOCK03" ] +instance Arbitrary EpochNo where + arbitrary = EpochNo <$> arbitrary + shrink = genericShrink + instance Arbitrary SlotId where shrink _ = [] arbitrary = do @@ -1165,6 +1356,36 @@ instance {-# OVERLAPS #-} Arbitrary (SlotParameters, SlotId) where (el', slot') <- shrink (el, slot) pure (SlotParameters el' sl st, slot') +-- | Combines a 'SlotParameters' object and a single point in time. +-- +-- The point in time falls into one of the following categories: +-- +-- 1. occurs during the lifetime of the blockchain; +-- 2. occurs before the earliest representable slot; +-- 3. occurs after the latest representable slot. +-- +data SlotParametersAndTimePoint = SlotParametersAndTimePoint + { getSlotParameters :: SlotParameters + , getTimePoint :: UTCTime + } deriving (Eq, Show) + +instance Arbitrary SlotParametersAndTimePoint where + arbitrary = do + sps <- arbitrary + let timeA = 0 + let timeB = toModifiedJulianDay $ utctDay $ epochStartTime sps minBound + let timeC = toModifiedJulianDay $ utctDay $ epochStartTime sps maxBound + let timeD = timeC * 2 + (lowerBound, upperBound) <- oneof $ fmap pure + [ (timeA, timeB) + , (timeB, timeC) + , (timeC, timeD) + ] + time <- genUniformTimeWithinRange + (ModifiedJulianDay lowerBound) + (ModifiedJulianDay upperBound) + pure $ SlotParametersAndTimePoint sps time + -- | Note, for functions which works with both an epoch length and a slot id, -- we need to make sure that the 'slotNumber' doesn't exceed the epoch length, -- otherwise, all computations get mixed up. diff --git a/lib/test-utils/src/Test/Utils/Time.hs b/lib/test-utils/src/Test/Utils/Time.hs index 6291dc3f776..416e647e549 100644 --- a/lib/test-utils/src/Test/Utils/Time.hs +++ b/lib/test-utils/src/Test/Utils/Time.hs @@ -9,13 +9,19 @@ module Test.Utils.Time ( UniformTime , genUniformTime + , genUniformTimeWithinRange , getUniformTime ) where import Prelude import Data.Time - ( Day (ModifiedJulianDay), NominalDiffTime, UTCTime (..), addUTCTime ) + ( Day (ModifiedJulianDay) + , NominalDiffTime + , UTCTime (..) + , addUTCTime + , toModifiedJulianDay + ) import Test.QuickCheck ( Arbitrary, Gen, arbitrary, choose, oneof ) @@ -31,30 +37,51 @@ instance Arbitrary UniformTime where -- | Generate 'UTCTime' values over a uniform range of dates and a mixture of -- time precisions. -- +-- Dates will be generated in a range that's bounded by 'defaultLowerBound' and +-- 'defaultUpperBound'. + genUniformTime :: Gen UTCTime -genUniformTime = oneof - [ genWith - hoursToNominalDiffTime - hoursInOneDay - , genWith - secondsToNominalDiffTime - secondsInOneDay - , genWith - picosecondsToNominalDiffTime - picosecondsInOneDay - ] +genUniformTime = genUniformTimeWithinRange defaultLowerBound defaultUpperBound + +-- | Generate 'UTCTime' values over a uniform range of dates and a mixture of +-- time precisions. +-- +-- Dates will be generated in a range that's bounded by the given minimum and +-- maximum Julian day arguments. +-- +genUniformTimeWithinRange :: Day -> Day -> Gen UTCTime +genUniformTimeWithinRange lowerBound upperBound + | lowerBound > upperBound = error $ + "genUniformTimeWithinRange: invalid bounds: " + <> show (lowerBound, upperBound) + | otherwise = oneof + [ genWith + hoursToNominalDiffTime + hoursInOneDay + , genWith + secondsToNominalDiffTime + secondsInOneDay + , genWith + picosecondsToNominalDiffTime + picosecondsInOneDay + ] where genWith :: (Integer -> NominalDiffTime) -> Integer -> Gen UTCTime genWith unitsToNominalDiffTime unitsInOneDay = do numberOfDays <- ModifiedJulianDay - <$> choose (0, daysInFiftyYears) + <$> choose + ( toModifiedJulianDay lowerBound + , toModifiedJulianDay upperBound + ) timeSinceMidnight <- unitsToNominalDiffTime <$> choose (0, unitsInOneDay) pure $ addUTCTime timeSinceMidnight (UTCTime numberOfDays 0) --- | The approximate number of days in fifty years. -daysInFiftyYears :: Integral a => a -daysInFiftyYears = 365 * 50 +defaultLowerBound :: Day +defaultLowerBound = ModifiedJulianDay 0 + +defaultUpperBound :: Day +defaultUpperBound = ModifiedJulianDay $ 365 * 50 -- | The number of hours in a day. hoursInOneDay :: Integral a => a