Skip to content

Commit

Permalink
Implement the peer simulator for Genesis tests (#434)
Browse files Browse the repository at this point in the history
This PR introduces an initial implementation of a Peer Simulator for
Genesis tests.

A simulated peer is told how to behave by a list of scheduled events
that is generated for every test. In essence, it is an implementation of
a ChainSync server and a BlockFetch server that behave as prescribed by
the schedule.

The rest of the PR provides support infrastructure in the form of:
 * a Praos client node that plays the role of subject under test,
 * an example test for long range attacks, and
* minimal definitions to generate a schedule from the chain schemas
introduced in #240.

This is a squash of the many commits in the integration branch of #367.
  • Loading branch information
facundominguez authored Dec 5, 2023
2 parents a98833a + 2076bac commit d866659
Show file tree
Hide file tree
Showing 26 changed files with 2,442 additions and 65 deletions.
27 changes: 27 additions & 0 deletions ouroboros-consensus-diffusion/ouroboros-consensus-diffusion.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -204,36 +204,63 @@ test-suite consensus-test
hs-source-dirs: test/consensus-test
main-is: Main.hs
other-modules:
Test.Consensus.BlockTree
Test.Consensus.Genesis.Setup
Test.Consensus.Genesis.Setup.Classifiers
Test.Consensus.Genesis.Setup.GenChains
Test.Consensus.Genesis.Tests
Test.Consensus.Genesis.Tests.LongRangeAttack
Test.Consensus.HardFork.Combinator
Test.Consensus.HardFork.Combinator.A
Test.Consensus.HardFork.Combinator.B
Test.Consensus.Network.AnchoredFragment.Extras
Test.Consensus.Network.Driver.Limits.Extras
Test.Consensus.Node
Test.Consensus.PeerSimulator.BlockFetch
Test.Consensus.PeerSimulator.Config
Test.Consensus.PeerSimulator.Handlers
Test.Consensus.PeerSimulator.Resources
Test.Consensus.PeerSimulator.Run
Test.Consensus.PeerSimulator.ScheduledChainSyncServer
Test.Consensus.PeerSimulator.Trace
Test.Consensus.PointSchedule

build-depends:
, base
, binary
, bytestring
, cardano-crypto-class
, cardano-slotting
, containers
, contra-tracer
, directory
, fs-api ^>=0.2
, fs-sim ^>=0.2
, hashable
, io-classes
, io-sim
, mtl
, nothunks
, ouroboros-consensus-diffusion
, ouroboros-consensus:{ouroboros-consensus, unstable-consensus-testlib}
, ouroboros-network
, ouroboros-network-api
, ouroboros-network-framework
, ouroboros-network-mock
, ouroboros-network-protocols
, QuickCheck
, quiet
, serialise
, si-timers
, sop-extras
, strict-sop-core
, strict-stm
, tasty
, tasty-hunit
, tasty-quickcheck
, temporary
, time
, typed-protocols
, typed-protocols-examples
, unstable-diffusion-testlib
, vector
2 changes: 2 additions & 0 deletions ouroboros-consensus-diffusion/test/consensus-test/Main.hs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
module Main (main) where

import qualified Test.Consensus.Genesis.Tests (tests)
import qualified Test.Consensus.HardFork.Combinator (tests)
import qualified Test.Consensus.Node (tests)
import Test.Tasty
Expand All @@ -18,4 +19,5 @@ tests =
Test.Consensus.HardFork.Combinator.tests
]
]
, Test.Consensus.Genesis.Tests.tests
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}

-- REVIEW: There is a `BlockTree` in `Test.Utils.TestBlock`. It relies on
-- different mechanisms but maybe we should rely on that instead to avoid
-- duplication.

module Test.Consensus.BlockTree (
BlockTree (..)
, BlockTreeBranch (..)
, PathAnchoredAtSource (..)
, addBranch
, addBranch'
, allFragments
, findFragment
, findPath
, mkTrunk
, prettyPrint
) where

import Cardano.Slotting.Block (BlockNo)
import Cardano.Slotting.Slot (SlotNo (unSlotNo))
import Data.Foldable (asum)
import Data.Function ((&))
import Data.Functor ((<&>))
import Data.Maybe (fromJust, fromMaybe)
import qualified Data.Vector as Vector
import Ouroboros.Consensus.Block.Abstract (blockNo, blockSlot,
unBlockNo)
import qualified Ouroboros.Network.AnchoredFragment as AF
import Text.Printf (printf)

-- | Represent a branch of a block tree by a prefix and a suffix. The full
-- fragment (the prefix and suffix catenated) is provided for practicality.
--
-- INVARIANT: the head of @btbPrefix@ is the anchor of @btbSuffix@.
--
-- INVARIANT: @btbFull == fromJust $ AF.join btbPrefix btbSuffix@
data BlockTreeBranch blk = BlockTreeBranch {
btbPrefix :: AF.AnchoredFragment blk,
btbSuffix :: AF.AnchoredFragment blk,
btbFull :: AF.AnchoredFragment blk
}
deriving (Show)

-- | Represent a block tree with a main trunk and branches leaving from the
-- trunk in question. All the branches are represented by their prefix to and
-- suffix from the intersection point.
--
-- INVARIANT: The branches' prefixes share the same anchor as the trunk and are
-- fully contained in the trunk.
--
-- INVARIANT: The branches' suffixes are anchored in the trunk and do not
-- contain any blocks in common with the trunk.
--
-- INVARIANT: The branches' suffixes do not contain any block in common with one
-- another.
data BlockTree blk = BlockTree {
btTrunk :: AF.AnchoredFragment blk,
btBranches :: [BlockTreeBranch blk]
}
deriving (Show)

-- | Make a block tree made of only a trunk.
mkTrunk :: AF.AnchoredFragment blk -> BlockTree blk
mkTrunk btTrunk = BlockTree { btTrunk, btBranches = [] }

-- | Add a branch to an existing block tree.
--
-- PRECONDITION: The given fragment intersects with the trunk or its anchor.
--
-- FIXME: we should enforce that the branch's prefix shares the same anchor as
-- the trunk.
--
-- FIXME: we should enforce that the new branch' suffix does not contain any
-- block in common with an existingbranch.
addBranch :: AF.HasHeader blk => AF.AnchoredFragment blk -> BlockTree blk -> Maybe (BlockTree blk)
addBranch branch bt = do
(_, btbPrefix, _, btbSuffix) <- AF.intersect (btTrunk bt) branch
-- NOTE: We could use the monadic bind for @Maybe@ here but we would rather
-- catch bugs quicker.
let btbFull = fromJust $ AF.join btbPrefix btbSuffix
pure $ bt { btBranches = BlockTreeBranch { .. } : btBranches bt }

-- | Same as @addBranch@ but assumes that the precondition holds.
addBranch' :: AF.HasHeader blk => AF.AnchoredFragment blk -> BlockTree blk -> BlockTree blk
addBranch' branch blockTree =
fromMaybe (error "addBranch': precondition does not hold") $ addBranch branch blockTree

-- | Return all the full fragments from the root of the tree.
allFragments :: BlockTree blk -> [AF.AnchoredFragment blk]
allFragments bt = btTrunk bt : map btbFull (btBranches bt)

-- | Look for a point in the block tree and return a fragment going from the
-- root of the tree to the point in question.
findFragment :: AF.HasHeader blk => AF.Point blk -> BlockTree blk -> Maybe (AF.AnchoredFragment blk)
findFragment point blockTree =
allFragments blockTree
& map (\fragment -> AF.splitAfterPoint fragment point)
& asum
<&> fst

-- | See 'findPath'.
newtype PathAnchoredAtSource = PathAnchoredAtSource Bool

-- | @findPath source target blockTree@ finds a path from the @source@ point to
-- the @target@ point in the @blockTree@ and returns it as an anchored fragment
-- It returns @Nothing@ when either of @source@ are @target@ are not in the
-- 'BlockTree'. There are two interesting properties on this fragment:
--
-- 1. Whether the returned fragment is anchored at the @source@.
-- 2. Whether the returned fragment is empty.
--
-- Together, those two properties form four interesting cases:
--
-- a. If the fragment is anchored at the @source@ and is empty, then @source
-- == target@.
--
-- b. If the fragment is anchored at the @source@ and is not empty, then
-- @source@ is an ancestor of @target@ and the fragment contains all the
-- blocks between them, @target@ included.
--
-- c. If the fragment is not anchored at the @source@ and is empty, then
-- @target@ is an ancestor of @source@.
--
-- d. If the fragment is not anchored at the @source@ and is not empty, then
-- it is anchored at the youngest common ancestor of both @source@ and
-- @target@ and contains all the blocks between that ancestor and @target@.
findPath ::
AF.HasHeader blk =>
AF.Point blk ->
AF.Point blk ->
BlockTree blk ->
Maybe (PathAnchoredAtSource, AF.AnchoredFragment blk)
findPath source target blockTree = do
sourceFragment <- findFragment source blockTree
targetFragment <- findFragment target blockTree
(_, _, _, targetSuffix) <- AF.intersect sourceFragment targetFragment
pure (
PathAnchoredAtSource (AF.anchorPoint targetSuffix == source),
targetSuffix
)

-- | Pretty prints a block tree for human readability. For instance:
--
-- slots: 0 1 2 3 4 5 6 7 8 9
-- trunk: 0─────1──2──3──4─────5──6──7
-- ╰─────3──4─────5
--
-- Returns a list of strings intended to be catenated with a newline.
prettyPrint :: AF.HasHeader blk => BlockTree blk -> [String]
prettyPrint blockTree = do
let honestFragment = btTrunk blockTree
let advFragment = btbSuffix $ head $ btBranches blockTree

let (oSlotNo, oBlockNo) = slotAndBlockNoFromAnchor $ AF.anchor honestFragment
let (hSlotNo, _) = slotAndBlockNoFromAnchor $ AF.headAnchor honestFragment

let (aoSlotNo, _) = slotAndBlockNoFromAnchor $ AF.anchor advFragment
let (ahSlotNo, _) = slotAndBlockNoFromAnchor $ AF.headAnchor advFragment

let firstSlotNo = min oSlotNo aoSlotNo
let lastSlotNo = max hSlotNo ahSlotNo

-- FIXME: only handles two fragments at this point. not very hard to make it
-- handle all of them. Some work needed to make it handle all of them _in a
-- clean way_.

[ "Block tree:"
,

[firstSlotNo .. lastSlotNo]
& map (printf "%2d" . unSlotNo)
& unwords
& (" slots: " ++)
,

honestFragment
& AF.toOldestFirst
& map (\block -> (fromIntegral (unSlotNo (blockSlot block) - 1), Just (unBlockNo (blockNo block))))
& Vector.toList . (Vector.replicate (fromIntegral (unSlotNo hSlotNo - unSlotNo oSlotNo)) Nothing Vector.//)
& map (maybe " " (printf "%2d"))
& unwords
& map (\c -> if c == ' ' then '' else c)
& ("" ++)
& (printf "%2d" (unBlockNo oBlockNo) ++)
& (" trunk: " ++)
,

advFragment
& AF.toOldestFirst
& map (\block -> (fromIntegral (unSlotNo (blockSlot block) - unSlotNo aoSlotNo - 1), Just (unBlockNo (blockNo block))))
& Vector.toList . (Vector.replicate (fromIntegral (unSlotNo ahSlotNo - unSlotNo aoSlotNo)) Nothing Vector.//)
& map (maybe " " (printf "%2d"))
& unwords
& map (\c -> if c == ' ' then '' else c)
& (" ╰─" ++)
& (replicate (3 * fromIntegral (unSlotNo (aoSlotNo - oSlotNo))) ' ' ++)
& (" " ++)
]

where
slotAndBlockNoFromAnchor :: AF.Anchor b -> (SlotNo, BlockNo)
slotAndBlockNoFromAnchor = \case
AF.AnchorGenesis -> (0, 0)
AF.Anchor slotNo _ blockNo' -> (slotNo, blockNo')
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE NamedFieldPuns #-}

module Test.Consensus.Genesis.Setup
( module Test.Consensus.Genesis.Setup,
module Test.Consensus.Genesis.Setup.GenChains
)
where

import Control.Monad.Class.MonadTime (MonadTime)
import Control.Monad.Class.MonadTimer.SI (MonadTimer)
import Control.Tracer (traceWith)
import Ouroboros.Consensus.Util.Condense
import Ouroboros.Consensus.Util.IOLike
import qualified Test.Consensus.BlockTree as BT
import Test.Consensus.PointSchedule
import Test.Consensus.PeerSimulator.Run
import Test.QuickCheck
import Test.Util.Orphans.IOLike ()
import Test.Util.Tracer (recordingTracerTVar)
import Test.Consensus.Genesis.Setup.GenChains

-- | Runs the given point schedule and evaluates the given property on the final
-- state view.
runTest ::
(IOLike m, MonadTime m, MonadTimer m) =>
GenesisTest ->
PointSchedule ->
(TestFragH -> Property) ->
m Property
runTest genesisTest@GenesisTest {gtBlockTree, gtHonestAsc} schedule makeProperty = do
(tracer, getTrace) <- recordingTracerTVar
-- let tracer = debugTracer

traceWith tracer $ "Honest active slot coefficient: " ++ show gtHonestAsc

mapM_ (traceWith tracer) $ BT.prettyPrint gtBlockTree

result <- runPointSchedule schedulerConfig genesisTest schedule tracer
trace <- unlines <$> getTrace

let
prop = case result of
Left exn ->
counterexample ("exception: " <> show exn) False
Right fragment ->
counterexample ("result: " <> condense fragment) (makeProperty fragment)

pure $ counterexample trace prop
where
schedulerConfig = SchedulerConfig {enableTimeouts = False}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}

module Test.Consensus.Genesis.Setup.Classifiers (
Classifiers (..)
, classifiers
) where

import Ouroboros.Consensus.Block.Abstract (SlotNo (SlotNo),
withOrigin)
import Ouroboros.Consensus.Config
import Ouroboros.Network.AnchoredFragment (anchor, anchorToSlotNo,
headSlot)
import qualified Ouroboros.Network.AnchoredFragment as AF
import Test.Consensus.BlockTree (BlockTree (..), BlockTreeBranch (..))
import Test.Consensus.Network.AnchoredFragment.Extras (slotLength)
import Test.Consensus.PointSchedule
import Test.Util.Orphans.IOLike ()

-- | Interesting categories to classify test inputs
data Classifiers =
Classifiers {
-- | There are more than k blocks in the alternative chain after the intersection
existsSelectableAdversary :: Bool,
-- | There are at least scg slots after the intesection on both the honest
-- and the alternative chain
--
-- Knowing if there is a Genesis window after the intersection is important because
-- otherwise the Genesis node has no chance to advance the immutable tip past
-- the Limit on Eagerness.
--
genesisWindowAfterIntersection :: Bool
}

classifiers :: GenesisTest -> Classifiers
classifiers GenesisTest {gtBlockTree, gtSecurityParam = SecurityParam k, gtGenesisWindow = GenesisWindow scg} =
Classifiers {existsSelectableAdversary, genesisWindowAfterIntersection}
where
genesisWindowAfterIntersection =
any fragmentHasGenesis branches

fragmentHasGenesis btb =
let
frag = btbSuffix btb
SlotNo intersection = withOrigin 0 id (anchorToSlotNo (anchor frag))
in isSelectable btb && slotLength frag > fromIntegral scg && goodTipSlot - intersection > scg

existsSelectableAdversary =
any isSelectable branches

isSelectable bt = AF.length (btbSuffix bt) > fromIntegral k

SlotNo goodTipSlot = withOrigin 0 id (headSlot goodChain)

branches = btBranches gtBlockTree

goodChain = btTrunk gtBlockTree
Loading

0 comments on commit d866659

Please sign in to comment.