packages feed

ychr-0.1.0.0: test/YCHR/RoundtripTest.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Roundtrip property tests: prettyTermSrc / prettyConstraintSrc are
-- right-inverses of the parser.
module YCHR.RoundtripTest (tests) where

import Data.List (intercalate)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Hedgehog
  ( Gen,
    Property,
    annotate,
    assert,
    evalIO,
    failure,
    forAll,
    forAllWith,
    property,
    (===),
  )
import Hedgehog.Gen qualified as Gen
import Hedgehog.Range qualified as Range
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertFailure, testCase, (@?=))
import Test.Tasty.Hedgehog (testProperty)
import YCHR.Internal.Parsed qualified as P
import YCHR.Internal.Parser (parseConstraint, parseRule, parseTerm)
import YCHR.Internal.Pretty (prettyConstraintSrc, prettyRuleSrc, prettyTermSrc)
import YCHR.Internal.Runtime.Monad (Chr, initSessionEnv, runChr)
import YCHR.Internal.Runtime.Registry (HostCallFn (..), baseHostCallRegistry, valueList)
import YCHR.Internal.Runtime.Types (Value (..))
import YCHR.Internal.Types (Constraint (..), Name (..), Term (..))
import YCHR.Internal.VM qualified as VM

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

-- | Generate a safe unquoted atom: lowercase-starting alphanumeric,
-- not a reserved word or infix operator name.
genSafeAtom :: Gen Text
genSafeAtom = Gen.filter isOk $ do
  c <- Gen.lower
  rest <- Gen.list (Range.linear 0 5) Gen.alphaNum
  pure (Text.pack (c : rest))
  where
    forbidden = ["is", "true", "false"] :: [Text]
    isOk s = s `notElem` forbidden && not (Text.null s)

-- | Generate atoms for use as 'AtomTerm' values, including cases that
-- require quoting (empty, uppercase-starting, embedded single quote).
genAtom :: Gen Text
genAtom =
  Gen.choice
    [ genSafeAtom,
      pure "",
      do
        s <- genSafeAtom
        pure (s <> "'s"),
      do
        c <- Gen.upper
        rest <- Gen.list (Range.linear 0 4) Gen.alphaNum
        pure (Text.pack (c : rest))
    ]

-- | Generate content for a 'TextTerm' (double-quoted string).
-- Includes plain text, embedded double quotes, backslashes, and escape chars.
genStringContent :: Gen Text
genStringContent =
  Text.pack
    <$> Gen.list
      (Range.linear 0 10)
      ( Gen.choice
          [ Gen.alphaNum,
            Gen.element [' ', '"', '\\', '\n', '\t']
          ]
      )

-- | Generate a valid variable name (uppercase-starting).
genVarName :: Gen Text
genVarName = do
  c <- Gen.upper
  rest <- Gen.list (Range.linear 0 4) (Gen.choice [Gen.alphaNum, pure '_'])
  pure (Text.pack (c : rest))

-- | Generate an arbitrary 'Term'. Compound terms use safe atom functors only
-- (no infix operators, no qualified names) so that 'prettyTermSrc' produces
-- output that parses back through 'termP'.
genTerm :: Gen Term
genTerm =
  Gen.recursive
    Gen.choice
    -- Base cases
    [ VarTerm <$> genVarName,
      IntTerm <$> Gen.integral (Range.linear (-1000) 1000),
      (\s -> CompoundTerm (Unqualified s) []) <$> genAtom,
      TextTerm <$> genStringContent,
      pure Wildcard
    ]
    -- Recursive cases (arity 1 and 2)
    [ Gen.subtermM genTerm $ \t -> do
        f <- genSafeAtom
        pure (CompoundTerm (Unqualified f) [t]),
      Gen.subtermM2 genTerm genTerm $ \t1 t2 -> do
        f <- genSafeAtom
        pure (CompoundTerm (Unqualified f) [t1, t2])
    ]

-- | Generate a constraint name (unqualified or qualified).
genConstraintName :: Gen Name
genConstraintName =
  Gen.choice
    [ Unqualified <$> genSafeAtom,
      Qualified <$> genSafeAtom <*> genSafeAtom
    ]

-- | Generate an arbitrary 'Constraint'.
genConstraint :: Gen Constraint
genConstraint = do
  name <- genConstraintName
  args <- Gen.list (Range.linear 0 3) genTerm
  pure (Constraint name args)

-- | Generate a parsed 'P.Head'.
genHead :: Gen P.Head
genHead =
  Gen.choice
    [ P.Simplification <$> Gen.list (Range.linear 1 3) genConstraint,
      P.Propagation <$> Gen.list (Range.linear 1 3) genConstraint,
      P.Simpagation
        <$> Gen.list (Range.linear 1 2) genConstraint
        <*> Gen.list (Range.linear 1 2) genConstraint
    ]

-- | Generate a parsed 'P.Rule'.
genRule :: Gen P.Rule
genRule = do
  name <- Gen.maybe (P.noAnn <$> genSafeAtom)
  hd <- genHead
  guard_ <- Gen.list (Range.linear 0 2) genTerm
  body_ <- Gen.list (Range.linear 1 3) genTerm
  pure
    P.Rule
      { name = name,
        head = P.noAnnP hd,
        guard = P.noAnnP guard_,
        body = P.noAnnP body_
      }

-- | Strip source locations from a parsed 'P.Rule' so we can compare
-- structurally (the parser produces real locations, generators use 'noAnn').
stripRuleAnn :: P.Rule -> P.Rule
stripRuleAnn r =
  P.Rule
    { name = P.noAnn . (.node) <$> r.name,
      head = P.noAnnP r.head.node,
      guard = P.noAnnP r.guard.node,
      body = P.noAnnP r.body.node
    }

-- ---------------------------------------------------------------------------
-- Properties
-- ---------------------------------------------------------------------------

prop_termRoundtrip :: Property
prop_termRoundtrip = property $ do
  t <- forAll genTerm
  let src = prettyTermSrc t
  case parseTerm "<roundtrip>" (Text.pack src) of
    Left err -> annotate (show err) >> failure
    Right t' -> t' === t

prop_constraintRoundtrip :: Property
prop_constraintRoundtrip = property $ do
  c <- forAll genConstraint
  let src = prettyConstraintSrc c
  case parseConstraint "<roundtrip>" (Text.pack src) of
    Left err -> annotate (show err) >> failure
    Right (Left validErr) -> annotate (show validErr) >> failure
    Right (Right c') -> c' === c

prop_ruleRoundtrip :: Property
prop_ruleRoundtrip = property $ do
  r <- forAll genRule
  let src = prettyRuleSrc r
  annotate src
  case parseRule "<roundtrip>" (Text.pack src) of
    Left err -> annotate (show err) >> failure
    Right (_, validErrs@(_ : _)) -> annotate (show validErrs) >> failure
    Right (Nothing, _) -> annotate "parser returned no rule" >> failure
    Right (Just r', []) -> stripRuleAnn r' === r

-- ---------------------------------------------------------------------------
-- =.. roundtrip
-- ---------------------------------------------------------------------------

-- | Generate a runtime 'Value' compound term (including arity 0).
-- The arity-0 base case is 'VAtom', matching the runtime collapse
-- enforced by 'YCHR.Internal.Compile.compileTerm'.
genCompoundValue :: Gen Value
genCompoundValue =
  Gen.recursive
    Gen.choice
    -- Base: arity-0 compound terms (canonical form is 'VAtom').
    [ VAtom <$> genSafeAtom
    ]
    -- Recursive: arity 1–3
    [ Gen.subtermM genLeafOrCompound $ \t -> do
        f <- genSafeAtom
        pure (VTerm f [t]),
      Gen.subtermM2 genLeafOrCompound genLeafOrCompound $ \t1 t2 -> do
        f <- genSafeAtom
        pure (VTerm f [t1, t2]),
      do
        f <- genSafeAtom
        args <- Gen.list (Range.linear 1 3) genLeafOrCompound
        pure (VTerm f args)
    ]
  where
    genLeafOrCompound =
      Gen.recursive
        Gen.choice
        [ VInt <$> Gen.integral (Range.linear (-100) 100),
          VAtom <$> genSafeAtom,
          VBool <$> Gen.bool
        ]
        [ genCompoundValue
        ]

-- | Structural equality for ground 'Value's (no 'VVar').
groundEq :: Value -> Value -> Bool
groundEq (VInt a) (VInt b) = a == b
groundEq (VAtom a) (VAtom b) = a == b
groundEq (VText a) (VText b) = a == b
groundEq (VBool a) (VBool b) = a == b
groundEq VWildcard VWildcard = True
groundEq (VTerm f1 as1) (VTerm f2 as2) =
  f1 == f2 && length as1 == length as2 && and (zipWith groundEq as1 as2)
groundEq _ _ = False

-- | Show a ground 'Value' for test diagnostics.
showGroundValue :: Value -> String
showGroundValue (VInt n) = "VInt " ++ show n
showGroundValue (VFloat n) = "VFloat " ++ show n
showGroundValue (VAtom a) = "VAtom " ++ show a
showGroundValue (VText t) = "VText " ++ show t
showGroundValue (VBool b) = "VBool " ++ show b
showGroundValue VWildcard = "VWildcard"
showGroundValue (VTerm f args) =
  "VTerm " ++ show f ++ " [" ++ intercalate ", " (map showGroundValue args) ++ "]"
showGroundValue (VVar _) = "VVar <opaque>"

-- | Look up a host call by name, failing if not found.
lookupHostCall :: VM.Name -> HostCallFn
lookupHostCall name = case Map.lookup name baseHostCallRegistry of
  Just hc -> hc
  Nothing -> error $ "host call not found: " ++ show name

runChrEmpty :: Chr a -> IO a
runChrEmpty action = do
  env <- initSessionEnv [] [] Map.empty Map.empty Map.empty Map.empty Set.empty
  runChr action env

prop_compoundToListRoundtrip :: Property
prop_compoundToListRoundtrip = property $ do
  term <- forAllWith showGroundValue genCompoundValue
  let HostCallFn toList = lookupHostCall "compound_to_list"
      HostCallFn fromList = lookupHostCall "list_to_compound"
  list <- evalIO (runChrEmpty (toList [term]))
  term' <- evalIO (runChrEmpty (fromList [list]))
  annotate (showGroundValue term)
  annotate (showGroundValue term')
  assert (groundEq term term')

prop_listToCompoundRoundtrip :: Property
prop_listToCompoundRoundtrip = property $ do
  f <- forAll genSafeAtom
  args <-
    forAllWith (show . map showGroundValue) $
      Gen.list (Range.linear 0 3) $
        Gen.choice [VInt <$> Gen.integral (Range.linear 0 100), VAtom <$> genSafeAtom]
  let list = valueList (VAtom f : args)
  let HostCallFn fromList = lookupHostCall "list_to_compound"
      HostCallFn toList = lookupHostCall "compound_to_list"
  compound <- evalIO (runChrEmpty (fromList [list]))
  list' <- evalIO (runChrEmpty (toList [compound]))
  annotate (showGroundValue list)
  annotate (showGroundValue list')
  assert (groundEq list list')

-- ---------------------------------------------------------------------------
-- Fixed-case roundtrips
-- ---------------------------------------------------------------------------
--
-- The Hedgehog rule generator above avoids infix operators in functors
-- and emits only safe-atom heads (see 'genTerm' / 'genHead'), so it
-- never produces a named simpagation, never threads a guard through a
-- propagation rule, etc. The fixed cases below exercise those
-- structural shapes head-on.
--
-- Arithmetic and comparison operators come from @library(prelude)@,
-- not 'builtinOps', so 'parseRule' can't re-ingest @+@/@<@/@==@ in
-- isolation. Pretty-printing of those is covered indirectly by the
-- ~120 golden tests that print arithmetic and comparison results.
--
-- The check is: parse → pretty → parse, and assert (a) both parses
-- succeed without parse-validation errors and (b) the pretty output
-- is a fixed point (rule1 and rule2 print to the same source). That
-- pins both the pretty-printer being a valid inverse of the parser
-- and the rendering being canonical for these shapes.

assertRuleRoundtrips :: Text -> IO ()
assertRuleRoundtrips src = case parseRule "<roundtrip>" src of
  Left err -> assertFailure ("parse failed on input:\n" ++ Text.unpack src ++ "\n" ++ show err)
  Right (_, validErrs@(_ : _)) ->
    assertFailure ("parse validation errors on input:\n" ++ show validErrs)
  Right (Nothing, _) ->
    assertFailure ("parser returned no rule for input:\n" ++ Text.unpack src)
  Right (Just rule1, []) -> do
    let pretty1 = prettyRuleSrc rule1
    case parseRule "<roundtrip>" (Text.pack pretty1) of
      Left err ->
        assertFailure ("re-parse failed on pretty output:\n" ++ pretty1 ++ "\n" ++ show err)
      Right (_, validErrs@(_ : _)) ->
        assertFailure ("re-parse validation errors on pretty output:\n" ++ show validErrs)
      Right (Nothing, _) ->
        assertFailure ("re-parse produced no rule on pretty output:\n" ++ pretty1)
      Right (Just rule2, []) -> prettyRuleSrc rule2 @?= pretty1

fixedRuleCases :: TestTree
fixedRuleCases =
  testGroup
    "rule fixed cases"
    [ testCase "named simpagation" $
        assertRuleRoundtrips "subsumes @ leq(X, Y) \\ leq(X, Y) <=> true.",
      testCase "named simpagation with guard (unification ask)" $
        assertRuleRoundtrips "subsumes @ leq(X, Y) \\ leq(X, Z) <=> Y = Z | true.",
      testCase "named propagation" $
        assertRuleRoundtrips "trans @ leq(X, Y), leq(Y, Z) ==> leq(X, Z).",
      testCase "named propagation with multi-atom body" $
        assertRuleRoundtrips
          "mul @ pair(X, Y), pair(Y, Z) ==> pair(X, Z), seen(Y).",
      testCase "simplification with multi-atom guard and body" $
        assertRuleRoundtrips
          "merge(X, Y, Z) <=> X = a, Y = b | step(X), step(Y), step(Z).",
      -- Multi-arg constraints with mixed argument kinds are syntactically
      -- distinctive: head separator, comma-arg lists, and the propagation
      -- arrow all interact.
      testCase "propagation with zero-arity head and body atoms" $
        assertRuleRoundtrips "go @ start, go ==> done."
    ]

-- ---------------------------------------------------------------------------
-- Test tree
-- ---------------------------------------------------------------------------

tests :: TestTree
tests =
  testGroup
    "YCHR.Roundtrip"
    [ testProperty "term roundtrip (parse . prettyTermSrc = id)" prop_termRoundtrip,
      testProperty
        "constraint roundtrip (parse . prettyConstraintSrc = id)"
        prop_constraintRoundtrip,
      testProperty "rule roundtrip (parse . prettyRuleSrc = id)" prop_ruleRoundtrip,
      fixedRuleCases,
      testProperty
        "compound_to_list roundtrip (list_to_compound . compound_to_list = id)"
        prop_compoundToListRoundtrip,
      testProperty
        "list_to_compound roundtrip (compound_to_list . list_to_compound = id)"
        prop_listToCompoundRoundtrip
    ]