packages feed

canontra-0.1.0.0: test/Canontra/PropertySpec.hs

{- |
Module      : Canontra.PropertySpec
Description : Property-based tests verifying core algebraic invariants for v0.0.3-alpha.

This module validates the essential mathematical guarantees:
- Zero-span source coordinate invariance
- Idempotence of normalization
- Byte-level determinism of binary serialization
- IEEE-754 float canonicalization (-0.0 == +0.0, NaN normalization)
- Unicode NFC precomposition normalization
- Multi-scope docstring stripping invariance
- Semantic string preservation
- Call graph invariance under formatting
- Structural sensitivity to semantic changes
- Repository order independence
-}
{-# LANGUAGE OverloadedStrings #-}
module Canontra.PropertySpec (spec) where

import qualified Data.Text as T
import Test.Hspec
import Test.QuickCheck

import Canontra.Cache.Inode (FileMetadata (..))
import Canontra.Cache.MerkleCache (decodeBinaryCache, emptyCache, encodeBinaryCache, insertCache, lookupBinaryCache)
import Canontra.Canonical.Float (canonicalizeFloatWord)
import Canontra.Canonical.Serialize (canonicalizeProgram)
import Canontra.Canonical.Unicode (canonicalizeText)
import Canontra.Fingerprint.Bundle (computeBundleFromSource)
import Canontra.Fingerprint.CallGraph (computeFCG)
import Canontra.Fingerprint.Source (hashBytes)
import Canontra.Fingerprint.Structural (computeF1)
import Canontra.Normalize.Normalize (normalizeProgram)
import Canontra.Parser.Python (parsePythonSource)
import Canontra.Repository.Parallel (parMapChunks)
import Canontra.Repository.Repository (computeRepositoryFingerprint)
import Canontra.Types

spec :: Spec
spec = do
  describe "Algebraic and Engine Properties" $ do

    it "Property: Normalization Idempotence N(N(P)) == N(P)" $ do
      let snippet = T.unlines
            [ "def compute(x: int, y: int = 0) -> int:"
            , "    # inline comment"
            , "    \"\"\"Docstring to strip.\"\"\""
            , "    z = x + y"
            , "    return z * 2"
            ]
      case parsePythonSource "sample.py" snippet of
        Left err -> expectationFailure (show (peReason err))
        Right prog -> do
          let norm1 = normalizeProgram prog
              norm2 = normalizeProgram norm1
          norm1 `shouldBe` norm2

    it "Property: Canonicalization Determinism C(P) == C(P)" $ do
      let snippet = T.unlines
            [ "class User:"
            , "    def __init__(self, name: str):"
            , "        self.name = name"
            , "    async def fetch(self):"
            , "        return self.name"
            ]
      case parsePythonSource "sample.py" snippet of
        Left err -> expectationFailure (show (peReason err))
        Right prog -> do
          let c1 = canonicalizeProgram (normalizeProgram prog)
              c2 = canonicalizeProgram (normalizeProgram prog)
          c1 `shouldBe` c2

    it "Property: IEEE-754 Float Canonicalization (-0.0 == +0.0, NaN normalization)" $ do
      let posZero = 0.0 :: Double
          negZero = -0.0 :: Double
          nan1 = 0/0 :: Double
          nan2 = (-0)/0 :: Double
      canonicalizeFloatWord negZero `shouldBe` canonicalizeFloatWord posZero
      canonicalizeFloatWord nan1 `shouldBe` canonicalizeFloatWord nan2

    it "Property: Unicode NFC Normalization (Precomposed == Decomposed)" $ do
      let decomposed = "caf\x0065\x0301" -- cafe with combining acute
          precomposed = "caf\x00e9"     -- café with precomposed é
      canonicalizeText decomposed `shouldBe` canonicalizeText precomposed

    it "Property: Zero-Span Relocation Invariance F1(P) == F1(T_relocate(P))" $ do
      let p1 = "def calc(a, b):\n    return a + b\n"
          p2 = "\n\n\ndef   calc(  a ,  b  ) :\n    return   a   +   b\n\n\n"
      case (computeBundleFromSource "1.py" p1, computeBundleFromSource "2.py" p2) of
        (Right b1, Right b2) -> f1Structural b1 `shouldBe` f1Structural b2
        (Left e, _) -> expectationFailure (show (peReason e))
        (_, Left e) -> expectationFailure (show (peReason e))

    it "Property: Formatting Invariance F1(P) == F1(T_format(P))" $ do
      let orig = "def add(a, b):\n    return a + b\n"
          fmt  = "def add(a,b):return a+b\n"
      case (computeBundleFromSource "orig.py" orig, computeBundleFromSource "fmt.py" fmt) of
        (Right b1, Right b2) -> f1Structural b1 `shouldBe` f1Structural b2
        (Left e, _) -> expectationFailure (show (peReason e))
        (_, Left e) -> expectationFailure (show (peReason e))

    it "Property: Comment Invariance F1(P) == F1(T_comment(P))" $ do
      let orig = "def process(items):\n    return [x * 2 for x in items]\n"
          comm = T.unlines
            [ "def process(items):"
            , "    # Double each element in items"
            , "    # Note: items must be iterable"
            , "    return [x * 2 for x in items]  # inline comment"
            ]
      case (computeBundleFromSource "orig.py" orig, computeBundleFromSource "comm.py" comm) of
        (Right b1, Right b2) -> f1Structural b1 `shouldBe` f1Structural b2
        (Left e, _) -> expectationFailure (show (peReason e))
        (_, Left e) -> expectationFailure (show (peReason e))

    it "Property: Multi-Scope Docstring Stripping Invariance" $ do
      let withDocs = T.unlines
            [ "\"\"\"Module level docstring.\"\"\""
            , "class Service:"
            , "    \"\"\"Class level docstring.\"\"\""
            , "    def handle(self):"
            , "        \"\"\"Function level docstring.\"\"\""
            , "        return 42"
            ]
          withoutDocs = T.unlines
            [ "class Service:"
            , "    def handle(self):"
            , "        return 42"
            ]
      case (computeBundleFromSource "doc.py" withDocs, computeBundleFromSource "nodoc.py" withoutDocs) of
        (Right b1, Right b2) -> f1Structural b1 `shouldBe` f1Structural b2
        (Left e, _) -> expectationFailure (show (peReason e))
        (_, Left e) -> expectationFailure (show (peReason e))

    it "Property: Semantic String Literal Preservation" $ do
      let p1 = "def get_msg():\n    return \"message A\"\n"
          p2 = "def get_msg():\n    return \"message B\"\n"
      case (computeBundleFromSource "1.py" p1, computeBundleFromSource "2.py" p2) of
        (Right b1, Right b2) -> f1Structural b1 `shouldNotBe` f1Structural b2
        (Left e, _) -> expectationFailure (show (peReason e))
        (_, Left e) -> expectationFailure (show (peReason e))

    it "Property: Call Graph Invariance under Formatting" $ do
      let p1 = "def a(): b()\ndef b(): c()\ndef c(): pass\n"
          p2 = "def a():\n    b()\n\ndef b():\n    c()\n\ndef c():\n    pass\n"
      case (parsePythonSource "1.py" p1, parsePythonSource "2.py" p2) of
        (Right prog1, Right prog2) -> computeFCG prog1 `shouldBe` computeFCG prog2
        _ -> expectationFailure "Parse failed"

    it "Property: Structural Sensitivity F1(P) /= F1(T_structural(P))" $ do
      let p1 = "def calc(a, b):\n    return a + b\n"
          p2 = "def calc(a, b):\n    return a - b\n"
      case (computeBundleFromSource "p1.py" p1, computeBundleFromSource "p2.py" p2) of
        (Right b1, Right b2) -> f1Structural b1 `shouldNotBe` f1Structural b2
        (Left e, _) -> expectationFailure (show (peReason e))
        (_, Left e) -> expectationFailure (show (peReason e))

    it "Property: Repository Order Independence F_R(P_pi) == F_R(P)" $ do
      let b1 = FingerprintBundle (Fingerprint "s1") (Fingerprint "str1") (Fingerprint "d1") (Fingerprint "dp1") (Fingerprint "cg1") (Fingerprint "cf1") (Fingerprint "df1") (Fingerprint "t1") (Fingerprint "c1")
          b2 = FingerprintBundle (Fingerprint "s2") (Fingerprint "str2") (Fingerprint "d2") (Fingerprint "dp2") (Fingerprint "cg2") (Fingerprint "cf2") (Fingerprint "df2") (Fingerprint "t2") (Fingerprint "c2")
          e1 = FileEntry "a.py" b1
          e2 = FileEntry "b.py" b2
          r1 = computeRepositoryFingerprint [e1, e2]
          r2 = computeRepositoryFingerprint [e2, e1]
      r1 `shouldBe` r2

    it "Property: Fused Streaming Hash Parity F1(P) == Hash(Canonicalize(Normalize(P)))" $ do
      let code = "def calculate(a: int, b: int = 10) -> int:\n    '''Docstring'''\n    x = a * 2\n    # Comment\n    if x > 5:\n        return x + b\n    return b\n"
      case parsePythonSource "test.py" code of
        Left err -> expectationFailure (show err)
        Right prog -> do
          let expected = hashBytes (canonicalizeProgram (normalizeProgram prog))
              actual = computeF1 prog
          unFingerprint actual `shouldBe` unFingerprint expected

    it "Property: Binary Merkle Cache Roundtrip Bijection" $
      property $ forAll (listOf (elements (['a'..'z'] ++ ['0'..'9'] ++ ['_', '/']))) $ \rawPath ->
        let path = if null rawPath then "app/main.py" else rawPath
            meta = FileMetadata path 1024 1700000000
            b = FingerprintBundle (Fingerprint "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
                                   (Fingerprint "f1a0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
                                   (Fingerprint "f2a0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
                                   (Fingerprint "f3a0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
                                   (Fingerprint "f4a0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
                                   (Fingerprint "f5a0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
                                   (Fingerprint "f6a0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
                                   (Fingerprint "")
                                   (Fingerprint "f7a0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
            cache = insertCache path meta b emptyCache
        in decodeBinaryCache (encodeBinaryCache cache) === Just cache

    it "Property: Multi-File CNTR v3 Cache Lookup Invariance" $
      property $ forAll (choose (1, 20 :: Int)) $ \n ->
        let indices = [1..n]
            paths = ["src/module_" ++ show i ++ ".py" | i <- indices]
            entries = [(p, FileMetadata p (fromIntegral (i * 100)) (1700000000 + fromIntegral i), FingerprintBundle (Fingerprint (T.pack ("s" ++ show i))) (Fingerprint (T.pack ("st" ++ show i))) (Fingerprint (T.pack ("d" ++ show i))) (Fingerprint (T.pack ("dp" ++ show i))) (Fingerprint (T.pack ("cg" ++ show i))) (Fingerprint (T.pack ("cf" ++ show i))) (Fingerprint (T.pack ("df" ++ show i))) (Fingerprint "") (Fingerprint (T.pack ("c" ++ show i)))) | (i, p) <- zip indices paths]
            cache = foldr (\(p, m, b) c -> insertCache p m b c) emptyCache entries
            bin = encodeBinaryCache cache
        in conjoin [lookupBinaryCache p m bin === Just b | (p, m, b) <- entries]

    it "Property: Work-Stealing List Order Invariance" $
      property $ \xs ->
        ioProperty $ do
          res <- parMapChunks pure (xs :: [Int])
          pure (res === xs)