packages feed

salmon-ops-recipes-0.2.0.0: test/Test/GraphFixtureSpec.hs

{-# LANGUAGE OverloadedStrings #-}

{- | Layer 0 coverage for "Test.GraphFixture" itself: the generator is only
useful to the suites that will lean on it if its closed forms are right, so
they are checked here three ways at small sizes: against a plain walk of the
unfold, against the expansion of the generated 'Op', and against its fold.

Nothing here measures anything. The one large case is opt-in (see
'largeEnv'); the default run stops at a few tens of thousands of nodes.
-}
module Test.GraphFixtureSpec (tests) where

import Control.Comonad.Cofree (Cofree (..))
import Control.Monad (forM_, unless)
import Control.Monad.Identity (runIdentity)
import Data.Dynamic (toDyn)
import Data.Foldable (toList)
import qualified Data.Map.Strict as Map
import Data.Maybe (isJust, isNothing)
import qualified Data.Set as Set
import System.Environment (lookupEnv)
import System.IO (hPutStrLn, stderr)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
import Text.Read (readMaybe)

import Salmon.Actions.UpDown (CheckResult (..))
import Salmon.Builtin.Extension (Extension, Op, check, dynamics, evalDeps, getDynamics, help, managed, notes, opAct, ref)
import Salmon.Op.Actions (Act (..))
import qualified Salmon.Op.Dag as Dag
import Salmon.Op.Graph (Graph (..))
import Salmon.Op.OpGraph (OpGraph (..))
import Salmon.Op.Ref (Ref)

import Test.GraphFixture

tests :: TestTree
tests =
    testGroup
        "Test.GraphFixture"
        [ testCase "the closed forms match a walk of the unfold" closedFormsMatchUnfold
        , testCase "the generated Op expands and folds to the closed forms" opMatchesClosedForms
        , testCase "a size is never exceeded, and chain, fan and tree hit it exactly" sizesAreRespected
        , testCase "the same seed gives the same graph, and only the random shape reads it" deterministic
        , testCase "a random layer keeps its own position and draws distinct others" randomLayerDraws
        , testCase "check, managed and dynamics are what the options say" nodeOptions
        , testCase "the edge form changes how edges are written, not the graph" edgeForms
        , testCase "the collision fraction yields exactly that many conflicting refs" collisions
        , testCase "describing a million nodes costs nothing until expanded" lazyDescription
        , testCase "every shape is walkable per node at tens of thousands of nodes" perNodeWalk
        , testCase ("a per-node walk at the size in " <> largeEnv <> " (opt-in)") largeWalk
        ]

-------------------------------------------------------------------------------

shapes :: [Shape]
shapes =
    [ Chain
    , Fan
    , Tree 1
    , Tree 2
    , Tree 3
    , Diamonds 1
    , Diamonds 2
    , Diamonds 3
    , Layered 1 1
    , Layered 3 1
    , Layered 4 2
    , Layered 3 3
    , RandomLayered 4 2
    , RandomLayered 5 3
    , RandomLayered 3 3
    ]

-- | Small enough that the shapes that share stay cheap to expand.
sizes :: [Int]
sizes = [0, 1, 2, 3, 4, 5, 7, 12, 20, 25]

seeds :: [Seed]
seeds = [0, 1, 42]

label :: Shape -> Int -> Seed -> String
label shape size seed = show shape <> " at " <> show size <> ", seed " <> show seed

{- | The fixture's counts obtained the slow way, from 'successors' alone,
together with the number of paths reaching each node.
-}
walkUnfold :: (Int -> [Int]) -> (Counts, Map.Map Int Integer, Set.Set (Int, Int))
walkUnfold next = (Counts nodeCount edgeCount occurrences leafPaths, paths, edges)
  where
    reachable = go Set.empty [0]
    go seen [] = seen
    go seen (i : rest)
        | Set.member i seen = go seen rest
        | otherwise = go (Set.insert i seen) (next i <> rest)

    edges = Set.fromList [(i, j) | i <- Set.toList reachable, j <- next i]

    -- a node's dependencies have larger numbers than it does, so ascending
    -- order has every path into a node counted before it is passed on.
    paths = foldl pass (Map.singleton 0 1) (Set.toAscList reachable)
    pass acc i =
        let here = Map.findWithDefault 0 i acc
         in foldl (\m j -> Map.insertWith (+) j here m) acc (next i)

    nodeCount = fromIntegral (Set.size reachable)
    edgeCount = fromIntegral (Set.size edges)
    occurrences = sum (Map.elems paths)
    leafPaths = sum [p | (i, p) <- Map.toList paths, null (next i)]

foldOf :: Op -> Dag.Dag Extension
foldOf = Dag.foldDag Dag.sameRepresentative . evalDeps

expansionLeaves :: Cofree Graph a -> Integer
expansionLeaves (_ :< g) =
    case toList g of
        [] -> 1
        below -> sum (fmap expansionLeaves below)

{- | The distinct 'Ref's of a graph, visiting each node once: what a consumer
that does not walk paths pays. An explicit stack, so depth costs heap rather
than stack.
-}
distinctRefs :: Op -> Int
distinctRefs root = go Set.empty [root]
  where
    go :: Set.Set Ref -> [Op] -> Int
    go seen [] = Set.size seen
    go seen (o : rest) =
        case opAct o of
            Just act
                | Set.member act.extension.ref seen -> go seen rest
                | otherwise -> go (Set.insert act.extension.ref seen) (below o <> rest)
            Nothing -> go seen (below o <> rest)

-- | What a node declares it depends on, one step down.
below :: Op -> [Op]
below o = toList (runIdentity o.predecessors)

-------------------------------------------------------------------------------

closedFormsMatchUnfold :: IO ()
closedFormsMatchUnfold =
    forM_ [(shape, size, seed) | shape <- shapes, size <- sizes, seed <- seeds] $ \(shape, size, seed) -> do
        let (walked, _, edges) = walkUnfold (successors shape size seed)
        assertEqual (label shape size seed) (counts shape size) walked
        assertBool
            (label shape size seed <> ": a dependency has a larger number than its dependant")
            (all (\(i, j) -> j > i) (Set.toList edges))

opMatchesClosedForms :: IO ()
opMatchesClosedForms =
    forM_ [(shape, size, seed) | shape <- shapes, size <- sizes, seed <- seeds] $ \(shape, size, seed) -> do
        let expected = counts shape size
            (_, _, edges) = walkUnfold (successors shape size seed)
            root = generate shape size seed
            expansion = evalDeps root
            dag = foldOf root
            at what = label shape size seed <> ": " <> what
        assertEqual (at "occurrences") expected.countOccurrences (fromIntegral (length expansion))
        assertEqual (at "leaf paths") expected.countLeafPaths (expansionLeaves expansion)
        assertEqual (at "nodes") expected.countNodes (fromIntegral (Map.size (Dag.dagNodes dag)))
        assertEqual (at "per-node walk") expected.countNodes (fromIntegral (distinctRefs root))
        -- the fold's edges are the unfold's, node for node: this is also
        -- what says the refs are distinct.
        assertEqual
            (at "edges")
            (Set.map (\(i, j) -> (nodeRef j, nodeRef i)) edges)
            (Dag.dagEdges dag)
        assertEqual (at "edge count") expected.countEdges (fromIntegral (Set.size (Dag.dagEdges dag)))
        assertEqual (at "the root is the only root") [nodeRef 0] (Dag.roots dag)
        assertEqual (at "no conflicts") 0 (length (Dag.dagConflicts dag))

sizesAreRespected :: IO ()
sizesAreRespected =
    forM_ [(shape, size) | shape <- shapes, size <- [1 .. 60]] $ \(shape, size) -> do
        let built = (counts shape size).countNodes
        assertBool (show shape <> " at " <> show size <> " built " <> show built) (built >= 1 && built <= fromIntegral size)
        case shape of
            Chain -> assertEqual "chain" (fromIntegral size) built
            Fan -> assertEqual "fan" (fromIntegral size) built
            Tree _ -> assertEqual "tree" (fromIntegral size) built
            _ -> pure ()

deterministic :: IO ()
deterministic = do
    let edgesOf shape size seed = Dag.dagEdges (foldOf (generate shape size seed))
    forM_ shapes $ \shape ->
        assertEqual (show shape <> " twice") (edgesOf shape 20 7) (edgesOf shape 20 7)
    forM_ [Chain, Fan, Tree 2, Diamonds 2, Layered 4 2] $ \shape ->
        assertEqual (show shape <> " ignores the seed") (edgesOf shape 20 1) (edgesOf shape 20 2)
    assertBool
        "the random shape differs between seeds"
        (edgesOf (RandomLayered 8 3) 41 1 /= edgesOf (RandomLayered 8 3) 41 2)
    assertBool
        "and differs from the regular one"
        (edgesOf (RandomLayered 8 3) 41 1 /= edgesOf (Layered 8 3) 41 1)

randomLayerDraws :: IO ()
randomLayerDraws = do
    let width = 8
        sharing = 3
        size = 1 + 6 * width
        next = successors (RandomLayered width sharing) size 99
    forM_ [1 .. size - 1 - width] $ \i -> do
        let picked = next i
        assertEqual ("node " <> show i <> " has its dependencies") sharing (length picked)
        assertEqual ("node " <> show i <> " draws distinct ones") sharing (Set.size (Set.fromList picked))
        assertEqual ("node " <> show i <> " keeps its own position") [i + width] (take 1 picked)
    forM_ [size - width .. size - 1] $ \i ->
        assertEqual ("node " <> show i <> " is in the last layer") [] (next i)

nodeOptions :: IO ()
nodeOptions = do
    let exts root = [act.extension | o <- toList (evalDeps root), Just act <- [opAct o]]
        plain = exts (generate (Tree 2) 7 0)
    assertEqual "seven nodes" 7 (length plain)
    verdicts <- mapM check plain
    assertEqual "no check by default" (replicate 7 Immaterial) verdicts
    assertBool "nothing managed by default" (all (isNothing . managed) plain)
    assertBool "no dynamics by default" (all (null . dynamics) plain)
    assertBool "no notes" (all (null . notes) plain)

    forM_ [Success, Completed, Failure "absent", Unknown] $ \verdict -> do
        answered <- mapM check (exts (generateWith defaultOptions{optCheck = verdict} (Tree 2) 7 0))
        assertEqual ("check answers " <> show verdict) (replicate 7 verdict) answered

    assertBool
        "every node managed"
        (all (isJust . managed) (exts (generateWith defaultOptions{optManaged = True} (Tree 2) 7 0)))

    let tagged = generateWith defaultOptions{optDynamics = \i -> [toDyn i]} (Tree 2) 7 0
    assertEqual
        "each node carries its own number"
        [0 .. 6 :: Int]
        (Set.toAscList (Set.fromList (concatMap getDynamics (toList (evalDeps tagged)))))

edgeForms :: IO ()
edgeForms = do
    let build form = generateWith defaultOptions{optEdges = form} (Layered 3 2) 10 0
        written form = runIdentity (build form).predecessors
    case written AsVertices of
        Vertices [_, _, _] -> pure ()
        other -> assertFailure ("AsVertices wrote " <> show (fmap (const ()) other))
    case written AsConnect of
        Connect (Vertices [_]) (Connect (Vertices [_]) (Connect (Vertices [_]) (Vertices []))) -> pure ()
        other -> assertFailure ("AsConnect wrote " <> show (fmap (const ()) other))
    case written AsOverlay of
        Overlay (Vertices [_]) (Overlay (Vertices [_]) (Overlay (Vertices [_]) (Vertices []))) -> pure ()
        other -> assertFailure ("AsOverlay wrote " <> show (fmap (const ()) other))
    forM_ [AsConnect, AsOverlay] $ \form -> do
        assertEqual (show form <> ": same edges") (Dag.dagEdges (foldOf (build AsVertices))) (Dag.dagEdges (foldOf (build form)))
        assertEqual (show form <> ": same order") (Dag.dagOrder (foldOf (build AsVertices))) (Dag.dagOrder (foldOf (build form)))

collisions :: IO ()
collisions =
    forM_ [(shape, fraction, seed) | shape <- shapes, fraction <- [0, 1 / 10, 1 / 3, 1], seed <- seeds] $ \(shape, fraction, seed) -> do
        let size = 20
            opts = defaultOptions{optCollisions = fraction}
            at what = label shape size seed <> ", colliding " <> show fraction <> ": " <> what
            expected = counts shape size
            wanted = colliderCount opts shape size
            (_, paths, _) = walkUnfold (successors shape size seed)
            chosen = filter (isCollider opts shape size seed) [-1 .. size + 1]
            root = generateWith opts shape size seed
            dag = foldOf root
            conflicting = Set.fromList (fmap (\c -> c.conflictRef) (Dag.dagConflicts dag))
        assertEqual (at "collider count") (floor (fraction * fromIntegral (expected.countNodes - 1))) wanted
        assertEqual (at "chosen nodes") wanted (fromIntegral (length chosen))
        assertEqual (at "conflicting refs") (Set.fromList (fmap nodeRef chosen)) conflicting
        assertEqual (at "nodes are unchanged") expected.countNodes (fromIntegral (Map.size (Dag.dagNodes dag)))
        assertEqual (at "edges are unchanged") expected.countEdges (fromIntegral (Set.size (Dag.dagEdges dag)))
        assertEqual
            (at "one more leaf per occurrence of a collider")
            (expected.countOccurrences + sum [paths Map.! c | c <- chosen])
            (fromIntegral (length (evalDeps root)))
        forM_ chosen $ \c ->
            assertEqual
                (at "the later declaration survives")
                (Just "graph fixture node (colliding declaration)")
                (fmap (\act -> act.extension.help) (Dag.representativeOf dag (nodeRef c)))

{- | The description is one closure: taking the root of a million-node graph,
and a few of its dependencies, must not build the rest. This is not a timing
assertion; a strict generator would show here as the suite stalling.
-}
lazyDescription :: IO ()
lazyDescription = do
    let million = 1000000
        refOf o = fmap (\act -> act.extension.ref) (opAct o)
    forM_ shapes $ \shape -> do
        let root = generate shape million 0
        assertEqual (show shape <> ": root") (Just (nodeRef 0)) (refOf root)
        case below root of
            first : _ -> assertEqual (show shape <> ": first dependency") (Just (nodeRef 1)) (refOf first)
            [] -> assertFailure (show shape <> ": a million-node graph whose root depends on nothing")
    assertEqual
        "a fan's root is over everything else"
        (fmap (Just . nodeRef) [1, 2, 3])
        (fmap refOf (take 3 (below (generate Fan million 0))))
    -- the closed forms are arithmetic, however large the expansion is.
    assertEqual "nodes of a million-node chain of diamonds" 1000000 (counts (Diamonds 2) million).countNodes
    assertBool
        "whose expansion could never be walked"
        ((counts (Diamonds 2) million).countOccurrences > 10 ^ (1000 :: Int))

perNodeWalk :: IO ()
perNodeWalk =
    forM_ shapes $ \shape -> do
        let size = 20000
        assertEqual (show shape) (counts shape size).countNodes (fromIntegral (distinctRefs (generate shape size 3)))

-- | Set to a node count (e.g. @1000000@) to run 'largeWalk'.
largeEnv :: String
largeEnv = "SALMON_TEST_GRAPH_FIXTURE_SIZE"

{- | Every shape at an operator-chosen size, visited once per node, and the
shapes without sharing expanded and folded as well. Opt-in: it is the one
case here whose cost is not negligible, and the host decides how much memory
it may take.
-}
largeWalk :: IO ()
largeWalk = do
    requested <- lookupEnv largeEnv
    case requested >>= readMaybe of
        Nothing ->
            hPutStrLn stderr ("SKIPPED: set " <> largeEnv <> " to a node count to walk every fixture shape at that size")
        Just size -> do
            forM_ shapes $ \shape ->
                assertEqual
                    (show shape <> ": per-node walk")
                    (counts shape size).countNodes
                    (fromIntegral (distinctRefs (generate shape size 3)))
            -- not the fan: the fold keeps each node's edges as a list it
            -- searches on every insert, so one node over everything else is
            -- quadratic there. That is a finding for whoever profiles, not
            -- something to wait on here.
            forM_ [Chain, Tree 2] $ \shape -> do
                let dag = foldOf (generate shape size 3)
                    expected = counts shape size
                unless (expected.countOccurrences == expected.countNodes) $
                    assertFailure (show shape <> " shares nodes")
                assertEqual (show shape <> ": folded nodes") expected.countNodes (fromIntegral (Map.size (Dag.dagNodes dag)))
                assertEqual (show shape <> ": folded edges") expected.countEdges (fromIntegral (Set.size (Dag.dagEdges dag)))