packages feed

moonlight-core-0.1.0.0: test/syntax/SubstitutionSpec.hs

{-# LANGUAGE DerivingStrategies #-}

module SubstitutionSpec (tests) where

import Control.Monad (foldM)
import Data.IntMap.Strict qualified as IntMap
import Data.List.NonEmpty (NonEmpty (..))
import Data.List.NonEmpty qualified as NonEmpty
import Data.Maybe (isJust, mapMaybe)
import Moonlight.Core (ClassId (..), PatternVar, mkPatternVar)
import Moonlight.Core (IsLawName (..), constructorLawName)
import Moonlight.Core
  ( Substitution (..),
    extendSubst,
    intersectRootedMatches,
    insertSubst,
    lookupSubst,
    mergeSubstitutions,
  )
import LawProperty (lawProperty)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit ((@?=), testCase)
import Test.Tasty.QuickCheck
  ( Arbitrary (..),
    Gen,
    Property,
    chooseInt,
    counterexample,
    listOf,
    property,
    resize,
    vectorOf,
    (===),
    (.&&.),
  )

data SubstitutionLaw
  = SubstitutionExtendAgreement
  | SubstitutionMergeIdempotent
  | SubstitutionMergeCommutative
  | SubstitutionMergeAssociative
  | SubstitutionMergeAgreement
  | SubstitutionRootedMatchIntersectionFiberProduct
  deriving stock (Bounded, Enum, Eq, Ord, Show)

instance IsLawName SubstitutionLaw where
  lawNameText =
    constructorLawName . show

newtype SmallPatternVar = SmallPatternVar PatternVar
  deriving stock (Eq, Ord, Show)

newtype SmallClassId = SmallClassId ClassId
  deriving stock (Eq, Ord, Show)

newtype SmallSubstitution = SmallSubstitution Substitution
  deriving stock (Eq, Ord, Show)

newtype RootedMatchSets = RootedMatchSets (NonEmpty [(ClassId, Substitution)])
  deriving stock (Eq, Show)

instance Arbitrary SmallPatternVar where
  arbitrary =
    SmallPatternVar <$> genPatternVar

instance Arbitrary SmallClassId where
  arbitrary =
    SmallClassId <$> genClassId

instance Arbitrary SmallSubstitution where
  arbitrary =
    SmallSubstitution <$> genSubstitution

instance Arbitrary RootedMatchSets where
  arbitrary =
    RootedMatchSets <$> genRootedMatchSets

tests :: TestTree
tests =
  testGroup
    "Substitution"
    [ lawProperty SubstitutionExtendAgreement propExtendAgreement,
      lawProperty SubstitutionMergeIdempotent propMergeIdempotent,
      lawProperty SubstitutionMergeCommutative propMergeCommutative,
      lawProperty SubstitutionMergeAssociative propMergeAssociative,
      lawProperty SubstitutionMergeAgreement propMergeAgreement,
      lawProperty SubstitutionRootedMatchIntersectionFiberProduct propRootedMatchIntersectionFiberProduct,
      testCase "rooted match intersection preserves left-major multiplicity inside class fibers" testRootedMatchIntersectionFiberOrder
    ]

propExtendAgreement :: SmallPatternVar -> SmallClassId -> SmallSubstitution -> Property
propExtendAgreement (SmallPatternVar patternVar) (SmallClassId classId) (SmallSubstitution substitution) =
  counterexample "extension success does not match agreement with the existing binding" (isJust extended === agrees)
    .&&. counterexample "successful extension does not contain the requested binding" (property resultContainsBinding)
  where
    extended =
      extendSubst patternVar classId substitution
    agrees =
      maybe True (== classId) (lookupSubst patternVar substitution)
    resultContainsBinding =
      maybe True ((== Just classId) . lookupSubst patternVar) extended

propMergeIdempotent :: SmallSubstitution -> Property
propMergeIdempotent (SmallSubstitution substitution) =
  mergeSubstitutions substitution substitution === Just substitution

propMergeCommutative :: SmallSubstitution -> SmallSubstitution -> Property
propMergeCommutative (SmallSubstitution left) (SmallSubstitution right) =
  mergeSubstitutions left right === mergeSubstitutions right left

propMergeAssociative :: SmallSubstitution -> SmallSubstitution -> SmallSubstitution -> Property
propMergeAssociative (SmallSubstitution left) (SmallSubstitution middle) (SmallSubstitution right) =
  mergeLeftFirst === mergeRightFirst
  where
    mergeLeftFirst =
      mergeSubstitutions left middle >>= \mergedLeft ->
        mergeSubstitutions mergedLeft right
    mergeRightFirst =
      mergeSubstitutions middle right >>= \mergedRight ->
        mergeSubstitutions left mergedRight

propMergeAgreement :: SmallSubstitution -> SmallSubstitution -> Property
propMergeAgreement (SmallSubstitution left) (SmallSubstitution right) =
  mergeSubstitutions left right === expectedMerge left right

propRootedMatchIntersectionFiberProduct :: RootedMatchSets -> Property
propRootedMatchIntersectionFiberProduct (RootedMatchSets rootedMatches) =
  intersectRootedMatches rootedMatches === expectedIntersectRootedMatches rootedMatches

testRootedMatchIntersectionFiberOrder :: IO ()
testRootedMatchIntersectionFiberOrder =
  intersectRootedMatches (leftMatches :| [rightMatches])
    @?= [ (ClassId 1, insertSubst (mkPatternVar 1) (ClassId 10) emptyLeft),
          (ClassId 1, insertSubst (mkPatternVar 2) (ClassId 20) emptyLeft),
          (ClassId 2, insertSubst (mkPatternVar 3) (ClassId 30) emptyLeft),
          (ClassId 1, insertSubst (mkPatternVar 1) (ClassId 10) emptyLeft),
          (ClassId 1, insertSubst (mkPatternVar 2) (ClassId 20) emptyLeft)
        ]
  where
    emptyLeft =
      Substitution IntMap.empty
    leftMatches =
      [(ClassId 1, emptyLeft), (ClassId 2, emptyLeft), (ClassId 1, emptyLeft)]
    rightMatches =
      [ (ClassId 1, insertSubst (mkPatternVar 1) (ClassId 10) emptyLeft),
        (ClassId 2, insertSubst (mkPatternVar 3) (ClassId 30) emptyLeft),
        (ClassId 1, insertSubst (mkPatternVar 2) (ClassId 20) emptyLeft)
      ]

expectedMerge :: Substitution -> Substitution -> Maybe Substitution
expectedMerge left right =
  if substitutionsAgree left right
    then Just (unionSubstitutions left right)
    else Nothing

substitutionsAgree :: Substitution -> Substitution -> Bool
substitutionsAgree (Substitution left) (Substitution right) =
  and (IntMap.intersectionWith (==) left right)

unionSubstitutions :: Substitution -> Substitution -> Substitution
unionSubstitutions (Substitution left) (Substitution right) =
  Substitution (IntMap.union left right)

expectedIntersectRootedMatches :: NonEmpty [(ClassId, Substitution)] -> [(ClassId, Substitution)]
expectedIntersectRootedMatches rootedMatches =
  mapMaybe rootedCombination (sequenceA (NonEmpty.toList rootedMatches))

rootedCombination :: [(ClassId, Substitution)] -> Maybe (ClassId, Substitution)
rootedCombination matches =
  case matches of
    [] ->
      Nothing
    (rootClassId, rootSubstitution) : remainingMatches
      | all ((== rootClassId) . fst) remainingMatches ->
          fmap
            (\mergedSubstitution -> (rootClassId, mergedSubstitution))
            (foldM mergeSubstitutions rootSubstitution (snd <$> remainingMatches))
      | otherwise ->
          Nothing

genPatternVar :: Gen PatternVar
genPatternVar =
  mkPatternVar <$> chooseInt (0, 5)

genClassId :: Gen ClassId
genClassId =
  ClassId <$> chooseInt (0, 5)

genSubstitution :: Gen Substitution
genSubstitution =
  Substitution . IntMap.fromList <$> resize 6 (listOf genEntry)

genEntry :: Gen (Int, ClassId)
genEntry =
  (,) <$> chooseInt (0, 5) <*> genClassId

genRootedMatchSets :: Gen (NonEmpty [(ClassId, Substitution)])
genRootedMatchSets =
  (:|)
    <$> genRootedMatchList
    <*> (chooseInt (0, 3) >>= \count -> vectorOf count genRootedMatchList)

genRootedMatchList :: Gen [(ClassId, Substitution)]
genRootedMatchList =
  chooseInt (0, 4) >>= \count ->
    vectorOf count genRootedMatch

genRootedMatch :: Gen (ClassId, Substitution)
genRootedMatch =
  (,) <$> genClassId <*> genSubstitution