packages feed

monoidal-functors-0.3.0.0: test/Data/Functor/Rank2/MonoidalSpec.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE MonoLocalBinds #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeFamilies #-}

-- | Exercises the generically derived rank-2 'Monoidal' for functor-interpreted
-- HKDs, at all three derivable instantiations:
--
-- * covariant product (@barbies@' @ApplicativeB@): 'combine' agrees with the
--   field-wise 'Pair'ing (@bprod@) and 'introduce' fills every field with
--   'Proxy' (@bpure Proxy@);
-- * coproduct: 'combine' injects a whole record all-'InL' or all-'InR';
-- * oplax product: 'combine' unzips a record of 'Pair's, and is inverse to the
--   covariant product 'combine'.
--
-- The covariant product also gets a laws pass (associativity, both unit laws,
-- and naturality), stated through @kindly-functors@' rank-2 'bmap1' with
-- associators and unitors written inline. This doubles as an interop check that
-- 'combine' commutes with 'bmap1'. The associators/unitors would collapse into
-- the shared "Data.Functor.Monoidal.Laws" machinery once the @Nat@-category
-- 'Control.Category.Tensor.Tensor' instances for 'Product' exist.
module Data.Functor.Rank2.MonoidalSpec (tests) where

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

import Control.Category.LawsSupport (genInt)
import Data.Functor.Contravariant (Op (..), getOp)
import Data.Functor.Identity (Identity (..))
import Data.Functor.Product (Product (..))
import Data.Functor.Rank2.Monoidal (Monoidal, Semigroupal (..), Unital (..), gcombineSum, gcombineThese, gsplitProduct)
import Data.Functor.Sum (Sum (..))
import Data.Functor.These (These1 (..))
import Data.Kind (Type)
import Data.Maybe (listToMaybe, maybeToList)
import Data.Proxy (Proxy (..))
import Data.These (These (..))
import Data.Void (Void, absurd)
import GHC.Generics (Generic, V1)
import Hedgehog (Gen, Group (..), Property, checkSequential, forAll, property, withTests, (===))
import Hedgehog.Gen qualified as Gen
import Hedgehog.Range qualified as Range
import Kindly (CategoricalFunctor (..), Nat (..), bmap1, type (~>))
import Prelude hiding (map)

--------------------------------------------------------------------------------
-- Test types

-- | A three-field record. Exercises @K1@, @M1@, and nested @:*:@.
data TestHKD f = TestHKD
  { thInt :: f Int,
    thBool :: f Bool,
    thString :: f String
  }
  deriving stock (Generic)

instance Semigroupal (->) Product (,) TestHKD

instance Unital (->) Proxy () TestHKD

instance Monoidal (->) Product Proxy (,) () TestHKD

instance Semigroupal (->) Sum Either TestHKD where combine = gcombineSum

instance Unital (->) V1 Void TestHKD where introduce = absurd

instance Monoidal (->) Sum V1 Either Void TestHKD

instance Semigroupal (->) These1 These TestHKD where combine = gcombineThese

instance Monoidal (->) These1 V1 These Void TestHKD

instance Semigroupal Op Product (,) TestHKD where combine = Op gsplitProduct

instance Unital Op Proxy () TestHKD where introduce = Op (const ())

instance Monoidal Op Product Proxy (,) () TestHKD

-- | 'TestHKD' as a @kindly-functors@ rank-2 functor, so the law properties can
-- map interpretations through it with 'bmap1'.
instance CategoricalFunctor TestHKD where
  type Dom TestHKD = (->) ~> (->)
  type Cod TestHKD = (->)
  map (Nat nt) (TestHKD a b c) = TestHKD (nt a) (nt b) (nt c)

deriving stock instance (Show (f Int), Show (f Bool), Show (f String)) => Show (TestHKD f)

deriving stock instance (Eq (f Int), Eq (f Bool), Eq (f String)) => Eq (TestHKD f)

-- | A nullary constructor. Exercises @U1@ and the unit path.
data EmptyHKD (f :: Type -> Type) = EmptyHKD
  deriving stock (Generic, Show, Eq)

instance Semigroupal (->) Product (,) EmptyHKD

instance Unital (->) Proxy () EmptyHKD

instance Monoidal (->) Product Proxy (,) () EmptyHKD

instance Semigroupal (->) Sum Either EmptyHKD where combine = gcombineSum

instance Unital (->) V1 Void EmptyHKD where introduce = absurd

instance Monoidal (->) Sum V1 Either Void EmptyHKD

instance Semigroupal (->) These1 These EmptyHKD where combine = gcombineThese

instance Monoidal (->) These1 V1 These Void EmptyHKD

--------------------------------------------------------------------------------
-- Monomorphic call sites (fix @cat@, @tC@, @tD@ for the derived instances).

combineHKD :: TestHKD f -> TestHKD g -> TestHKD (Product f g)
combineHKD x y = combine @(->) @Product @(,) (x, y)

introduceHKD :: TestHKD Proxy
introduceHKD = introduce @(->) @Proxy @() ()

combineSumHKD :: Either (TestHKD f) (TestHKD g) -> TestHKD (Sum f g)
combineSumHKD = combine @(->) @Sum @Either

combineTheseHKD :: These (TestHKD f) (TestHKD g) -> TestHKD (These1 f g)
combineTheseHKD = combine @(->) @These1 @These

splitHKD :: TestHKD (Product f g) -> (TestHKD f, TestHKD g)
splitHKD = getOp (combine @Op @Product @(,))

-- | The reference for the covariant product 'combine': field-wise 'Pair'.
refCombine :: TestHKD f -> TestHKD g -> TestHKD (Product f g)
refCombine (TestHKD a b c) (TestHKD a' b' c') =
  TestHKD (Pair a a') (Pair b b') (Pair c c')

-- | The reference for the coproduct 'combine': every field on one side.
refCombineSum :: Either (TestHKD f) (TestHKD g) -> TestHKD (Sum f g)
refCombineSum (Left (TestHKD a b c)) = TestHKD (InL a) (InL b) (InL c)
refCombineSum (Right (TestHKD a b c)) = TestHKD (InR a) (InR b) (InR c)

-- | The reference for the These 'combine': every field follows the outer 'These'.
refCombineThese :: These (TestHKD f) (TestHKD g) -> TestHKD (These1 f g)
refCombineThese (This (TestHKD a b c)) = TestHKD (This1 a) (This1 b) (This1 c)
refCombineThese (That (TestHKD a b c)) = TestHKD (That1 a) (That1 b) (That1 c)
refCombineThese (These (TestHKD a b c) (TestHKD a' b' c')) =
  TestHKD (These1 a a') (These1 b b') (These1 c c')

--------------------------------------------------------------------------------
-- Associators and unitors for the 'Product' tensor on the functor category.
-- These stand in for the (as yet unwritten) @Nat@-category 'Tensor' instances.

-- | 'Product'\'s action on morphisms (the functor-category bifunctor).
prodNat ::
  (forall x. f x -> f' x) ->
  (forall x. g x -> g' x) ->
  Product f g a ->
  Product f' g' a
prodNat l r (Pair p q) = Pair (l p) (r q)

-- | The associator of 'Product' on the functor category.
assocProd :: Product f (Product g h) a -> Product (Product f g) h a
assocProd (Pair a (Pair b c)) = Pair (Pair a b) c

-- | The right unitor: 'Proxy' is the unit of 'Product'.
runitProd :: Product f Proxy a -> f a
runitProd (Pair a _) = a

-- | The left unitor.
lunitProd :: Product Proxy f a -> f a
lunitProd (Pair _ a) = a

--------------------------------------------------------------------------------
-- Generators

genString :: Gen String
genString = Gen.string (Range.linear 0 5) Gen.alpha

-- | Build a 'TestHKD' generator from a per-field wrapper.
genHKD :: (forall a. Gen a -> Gen (f a)) -> Gen (TestHKD f)
genHKD f = TestHKD <$> f genInt <*> f Gen.bool <*> f genString

genMaybeHKD :: Gen (TestHKD Maybe)
genMaybeHKD = genHKD Gen.maybe

genListHKD :: Gen (TestHKD [])
genListHKD = genHKD (Gen.list (Range.linear 0 3))

genIdentityHKD :: Gen (TestHKD Identity)
genIdentityHKD = genHKD (fmap Identity)

genProductHKD :: Gen (TestHKD (Product Maybe []))
genProductHKD = genHKD (\g -> Pair <$> Gen.maybe g <*> Gen.list (Range.linear 0 3) g)

-- | A 'These' generator that hits all three cases.
genTheseHKD :: Gen (These (TestHKD Maybe) (TestHKD []))
genTheseHKD =
  Gen.choice
    [ This <$> genMaybeHKD,
      That <$> genListHKD,
      These <$> genMaybeHKD <*> genListHKD
    ]

--------------------------------------------------------------------------------
-- Covariant product: agreement with the reference

-- | 'combine' agrees with the hand-written field-wise 'Pair' (@bprod@).
combineAgreesWithRef :: Property
combineAgreesWithRef = property $ do
  x <- forAll genMaybeHKD
  y <- forAll genListHKD
  combineHKD x y === refCombine x y

-- | 'combine' at a homogeneous interpretation also agrees with the reference.
combineAgreesWithRefHomogeneous :: Property
combineAgreesWithRefHomogeneous = property $ do
  x <- forAll genIdentityHKD
  y <- forAll genIdentityHKD
  combineHKD x y === refCombine x y

-- | 'introduce' fills every field with 'Proxy' (@bpure Proxy@).
introduceIsAllProxy :: Property
introduceIsAllProxy = withTests 1 $ property $ do
  introduceHKD === TestHKD Proxy Proxy Proxy

--------------------------------------------------------------------------------
-- Covariant product: monoidal laws (via kindly's rank-2 'bmap1')

-- | Naturality: 'combine' commutes with maps into either tensor position.
-- Doubles as the interop check that 'combine' commutes with 'bmap1'.
naturalityLaw :: Property
naturalityLaw = property $ do
  x <- forAll genMaybeHKD
  y <- forAll genListHKD
  bmap1 (prodNat maybeToList listToMaybe) (combineHKD x y)
    === combineHKD (bmap1 maybeToList x) (bmap1 listToMaybe y)

-- | Associativity: reassociating the domain 'Product' turns the right-nested
-- 'combine' into the left-nested one.
associativityLaw :: Property
associativityLaw = property $ do
  x <- forAll genMaybeHKD
  y <- forAll genListHKD
  z <- forAll genIdentityHKD
  bmap1 assocProd (combineHKD x (combineHKD y z))
    === combineHKD (combineHKD x y) z

-- | Right unit: combining with @'introduce' ()@ and projecting recovers the record.
rightUnitLaw :: Property
rightUnitLaw = property $ do
  x <- forAll genMaybeHKD
  bmap1 runitProd (combineHKD x introduceHKD) === x

-- | Left unit: the mirror of 'rightUnitLaw'.
leftUnitLaw :: Property
leftUnitLaw = property $ do
  x <- forAll genMaybeHKD
  bmap1 lunitProd (combineHKD introduceHKD x) === x

--------------------------------------------------------------------------------
-- Coproduct

-- | Injecting on the left sends every field to 'InL'.
coproductInjectsLeft :: Property
coproductInjectsLeft = property $ do
  x <- forAll genMaybeHKD
  let e = Left x :: Either (TestHKD Maybe) (TestHKD [])
  combineSumHKD e === refCombineSum e

-- | Injecting on the right sends every field to 'InR'.
coproductInjectsRight :: Property
coproductInjectsRight = property $ do
  y <- forAll genListHKD
  let e = Right y :: Either (TestHKD Maybe) (TestHKD [])
  combineSumHKD e === refCombineSum e

--------------------------------------------------------------------------------
-- These

-- | 'combine' at the 'These1' tensor follows the outer 'These' field-wise,
-- across all three cases.
theseAgreesWithRef :: Property
theseAgreesWithRef = property $ do
  e <- forAll genTheseHKD
  combineTheseHKD e === refCombineThese e

--------------------------------------------------------------------------------
-- Oplax product (split)

-- | Split then combine is the identity (product iso, one direction).
splitAfterCombine :: Property
splitAfterCombine = property $ do
  x <- forAll genMaybeHKD
  y <- forAll genListHKD
  splitHKD (combineHKD x y) === (x, y)

-- | Combine then split is the identity (product iso, other direction).
combineAfterSplit :: Property
combineAfterSplit = property $ do
  z <- forAll genProductHKD
  uncurry combineHKD (splitHKD z) === z

--------------------------------------------------------------------------------
-- Field-less record

-- | The field-less record is handled by the @U1@ path at every instantiation.
emptyRecord :: Property
emptyRecord = withTests 1 $ property $ do
  combine @(->) @Product @(,) (EmptyHKD :: EmptyHKD Maybe, EmptyHKD :: EmptyHKD []) === EmptyHKD
  introduce @(->) @Proxy @() () === (EmptyHKD :: EmptyHKD Proxy)
  combine @(->) @Sum @Either (Left EmptyHKD :: Either (EmptyHKD Maybe) (EmptyHKD [])) === EmptyHKD
  combine @(->) @These1 @These (These EmptyHKD EmptyHKD :: These (EmptyHKD Maybe) (EmptyHKD [])) === EmptyHKD

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

tests :: IO Bool
tests =
  checkSequential $
    Group
      "Data.Functor.Rank2.Monoidal"
      [ ("combine agrees with reference (Maybe/[])", combineAgreesWithRef),
        ("combine agrees with reference (Identity)", combineAgreesWithRefHomogeneous),
        ("introduce is all Proxy", introduceIsAllProxy),
        ("naturality (combine commutes with bmap1)", naturalityLaw),
        ("associativity", associativityLaw),
        ("right unit", rightUnitLaw),
        ("left unit", leftUnitLaw),
        ("coproduct injects left", coproductInjectsLeft),
        ("coproduct injects right", coproductInjectsRight),
        ("these follows outer These", theseAgreesWithRef),
        ("split . combine = id", splitAfterCombine),
        ("combine . split = id", combineAfterSplit),
        ("empty record at every instantiation", emptyRecord)
      ]