packages feed

moonlight-planar-1.1.0.0: test/exact-planar/Moonlight/Planar/InCircleFixedWidthSpec.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE NumericUnderscores #-}

-- | The fixed-width in-circle worker against the arbitrary-precision
-- determinant it stands in for. Agreement on every case is the law; the
-- count of cases the worker resolved by itself is the receipt that the law
-- was observed on the worker and not on the fallback it declines into.
module Moonlight.Planar.InCircleFixedWidthSpec (tests) where

import Control.Monad (unless)
import Data.Bits (finiteBitSize, shiftL, shiftR, xor, (.&.), (.|.))
import qualified Data.List as List
import Data.Word (Word64)
import GHC.Float (castDoubleToWord64, castWord64ToDouble)
import Moonlight.Planar.Internal.Dyadic
  ( exactInCircleDet
  , exactInCircleSignFixedWidth
  )

data Quad = Quad !Double !Double !Double !Double !Double !Double !Double !Double
  deriving stock (Show)

-- | What the worker owes a family on a 64-bit word: every case, because the
-- family's exponents always align within the width, or at least one refusal,
-- because the family exists to keep the fallback on the record.
data Expectation = ResolvesEvery | DeclinesSome

data Family = Family
  { familyName :: String
  , familyQuads :: [Quad]
  , familyExpectation :: Expectation
  , familyCocircular :: Bool
  }

data Tally = Tally
  { tallyCases :: !Int
  , tallyResolved :: !Int
  , tallyDeclined :: !Int
  , tallyDisagreement :: Maybe Quad
  , tallyNotCocircular :: Maybe Quad
  }

tests :: IO ()
tests = do
  tallies <- traverse observe families
  let total = sum (map tallyCases tallies)
  unless (total >= 10_000) $
    fail ("in-circle fixed-width law observed on too few cases: " <> show total)

observe :: Family -> IO Tally
observe family = do
  let tally = List.foldl' (record (familyCocircular family)) emptyTally (familyQuads family)
  putStrLn
    ( "in-circle fixed width " <> familyName family
        <> ": cases=" <> show (tallyCases tally)
        <> " resolved=" <> show (tallyResolved tally)
        <> " declined=" <> show (tallyDeclined tally)
    )
  case tallyNotCocircular tally of
    Just quad -> fail (familyName family <> ": fixture is not exactly cocircular: " <> show quad)
    Nothing -> pure ()
  case tallyDisagreement tally of
    Just quad ->
      fail
        ( familyName family <> ": fixed-width sign disagrees with the determinant on "
            <> show quad <> " (determinant " <> show (determinant quad) <> ")"
        )
    Nothing -> pure ()
  case familyExpectation family of
    ResolvesEvery
      | wideWord && tallyDeclined tally /= 0 ->
          fail (familyName family <> ": the worker declined " <> show (tallyDeclined tally) <> " cases it should resolve")
      | not wideWord && tallyResolved tally /= 0 ->
          fail (familyName family <> ": a narrow word resolved cases in fixed width")
    DeclinesSome
      | tallyDeclined tally == 0 ->
          fail (familyName family <> ": the fallback was never taken")
    _ -> pure ()
  pure tally
 where
  wideWord = finiteBitSize (0 :: Word) == 64

emptyTally :: Tally
emptyTally = Tally 0 0 0 Nothing Nothing

record :: Bool -> Tally -> Quad -> Tally
record cocircular tally quad =
  let !exact = determinant quad
      !expected = compare exact 0
      counted = tally{tallyCases = tallyCases tally + 1}
      checked
        | cocircular && exact /= 0 =
            counted{tallyNotCocircular = maybe (Just quad) Just (tallyNotCocircular counted)}
        | otherwise = counted
   in case fixedWidth quad of
        Nothing -> checked{tallyDeclined = tallyDeclined checked + 1}
        Just verdict
          | verdict == expected -> checked{tallyResolved = tallyResolved checked + 1}
          | otherwise ->
              checked
                { tallyResolved = tallyResolved checked + 1
                , tallyDisagreement = maybe (Just quad) Just (tallyDisagreement checked)
                }

determinant :: Quad -> Integer
determinant (Quad ax ay bx by cx cy dx dy) = exactInCircleDet ax ay bx by cx cy dx dy

fixedWidth :: Quad -> Maybe Ordering
fixedWidth (Quad ax ay bx by cx cy dx dy) = exactInCircleSignFixedWidth ax ay bx by cx cy dx dy

families :: [Family]
families =
  [ Family "uniform" (take 3_000 (uniformQuads (stream 0x1ed1_b2ad_5c0f_9e31))) ResolvesEvery False
  , Family "wide exponents" (take 2_000 (wideQuads (stream 0x7a5f_3c21_9b8e_d604))) DeclinesSome False
  , Family "subnormal floor" (take 1_000 (subnormalQuads (stream 0x3f8c_2d17_a9e4_b055))) ResolvesEvery False
  , Family "grid cocircular" (take 1_500 (gridCocircularQuads (stream 0x9c4e_71b3_5d2a_f086))) ResolvesEvery True
  , Family "grid one unit off" (take 1_000 (gridOffQuads (stream 0x2b7d_e8a1_4f63_c917))) ResolvesEvery False
  , Family "integer circle cocircular" (take 1_500 (circleCocircularQuads (stream 0x6e2a_9d5c_1b74_38f0))) ResolvesEvery True
  , Family "integer circle one unit off" (take 1_000 (circleOffQuads (stream 0xd41b_7c8e_2a95_6f13))) ResolvesEvery False
  , Family "hex lattice hexagon" (take 1_500 (hexHexagonQuads (stream 0x58f3_a6d2_c9e1_74b8))) ResolvesEvery False
  , Family "hex lattice random" (take 500 (hexRandomQuads (stream 0xa7c4_2e91_6d3b_f508))) ResolvesEvery False
  , Family "one ulp off cocircular" (take 1_500 (ulpOffQuads (stream 0x14e9_b7d6_3a2c_85f1))) ResolvesEvery False
  , Family "degenerate" (take 500 (degenerateQuads (stream 0xc3a8_5f1d_7e26_49b0))) ResolvesEvery False
  ]

-- SplitMix64: one deterministic word stream per family.
stream :: Word64 -> [Word64]
stream seed =
  let !state = seed + 0x9E37_79B9_7F4A_7C15
      !z1 = (state `xor` (state `shiftR` 30)) * 0xBF58_476D_1CE4_E5B9
      !z2 = (z1 `xor` (z1 `shiftR` 27)) * 0x94D0_49BB_1331_11EB
   in (z2 `xor` (z2 `shiftR` 31)) : stream state

chunks :: Int -> [a] -> [[a]]
chunks width values =
  case splitAt width values of
    (chunk, rest) | length chunk == width -> chunk : chunks width rest
    _ -> []

quadOf :: [Double] -> Quad
quadOf [ax, ay, bx, by, cx, cy, dx, dy] = Quad ax ay bx by cx cy dx dy
quadOf other = error ("quadOf: " <> show (length other) <> " coordinates")

unit :: Word64 -> Double
unit word = fromIntegral (word `shiftR` 11) / 9_007_199_254_740_992

signed :: Word64 -> Double
signed word = 2 * unit word - 1

modulo :: Word64 -> Int -> Int
modulo word bound = fromIntegral (word `mod` fromIntegral bound)

integerIn :: Int -> Word64 -> Double
integerIn bound word = fromIntegral (modulo word (2 * bound + 1) - bound)

uniformQuads :: [Word64] -> [Quad]
uniformQuads = map (quadOf . map signed) . chunks 8

-- Any finite bit pattern: sign, exponent field in [0, 2046], random mantissa.
wideQuads :: [Word64] -> [Quad]
wideQuads = map (quadOf . map (fromBits 2047)) . chunks 8

-- Exponent fields in [0, 74]: subnormals and the normals within 73 bits of
-- their floor, so every alignment shift fits the width.
subnormalQuads :: [Word64] -> [Quad]
subnormalQuads = map (quadOf . map (fromBits 75)) . chunks 8

fromBits :: Int -> Word64 -> Double
fromBits exponentBound word =
  let signBit = (word `shiftR` 63) `shiftL` 63
      exponentField = fromIntegral (modulo (word `shiftR` 52) exponentBound) `shiftL` 52
      mantissa = word .&. 0x000F_FFFF_FFFF_FFFF
   in castWord64ToDouble (signBit .|. exponentField .|. mantissa)

-- The four corners of a rectangle centred on an integer point: all on one
-- circle, in a random order.
gridCocircularQuads :: [Word64] -> [Quad]
gridCocircularQuads = map corners . chunks 5
 where
  corners [cxWord, cyWord, aWord, bWord, orderWord] =
    let cx = integerIn 1_048_576 cxWord
        cy = integerIn 1_048_576 cyWord
        a = fromIntegral (1 + modulo aWord 1_048_576)
        b = fromIntegral (1 + modulo bWord 1_048_576)
     in permuted orderWord [(cx + a, cy + b), (cx - a, cy + b), (cx - a, cy - b), (cx + a, cy - b)]
  corners _ = error "gridCocircularQuads"

gridOffQuads :: [Word64] -> [Quad]
gridOffQuads source =
  zipWith nudge (gridCocircularQuads source) (stream 0x71c5_2d9e_b384_a6f0)

permuted :: Word64 -> [(Double, Double)] -> Quad
permuted word points =
  quadOf (concatMap (\(x, y) -> [x, y]) (List.permutations points !! modulo word 24))

-- Move one coordinate of the fourth point by one unit; on integer fixtures
-- that is an exact step off the circle.
nudge :: Quad -> Word64 -> Quad
nudge (Quad ax ay bx by cx cy dx dy) word =
  case modulo word 4 of
    0 -> Quad ax ay bx by cx cy (dx + 1) dy
    1 -> Quad ax ay bx by cx cy (dx - 1) dy
    2 -> Quad ax ay bx by cx cy dx (dy + 1)
    _ -> Quad ax ay bx by cx cy dx (dy - 1)

-- Every lattice point on the circle of radius 5525 = 5^2 * 13 * 17, a radius
-- chosen for its many representations as a sum of two squares.
circlePoints :: [(Integer, Integer)]
circlePoints =
  List.nub
    [ (sx * x, sy * y)
    | x <- [0 .. radius]
    , let remaining = radius * radius - x * x
          y = integerSquareRoot remaining
    , y * y == remaining
    , sx <- [1, -1]
    , sy <- [1, -1]
    ]
 where
  radius = 5525 :: Integer

integerSquareRoot :: Integer -> Integer
integerSquareRoot value =
  let guess = floor (sqrt (fromIntegral value :: Double))
   in List.foldl' (\best candidate -> if candidate * candidate <= value then max best candidate else best) 0 [guess - 1, guess, guess + 1]

circleCocircularQuads :: [Word64] -> [Quad]
circleCocircularQuads = map pick . filter distinct . chunks 7
 where
  count = length circlePoints
  distinct [a, b, c, d, _, _, _] =
    let indices = map (`modulo` count) [a, b, c, d]
     in length (List.nub indices) == 4
  distinct _ = False
  pick [a, b, c, d, txWord, tyWord, orderWord] =
    let tx = integerIn 1_073_741_824 txWord
        ty = integerIn 1_073_741_824 tyWord
        point index =
          let (x, y) = circlePoints !! modulo index count
           in (fromIntegral x + tx, fromIntegral y + ty)
     in permuted orderWord (map point [a, b, c, d])
  pick _ = error "circleCocircularQuads"

circleOffQuads :: [Word64] -> [Quad]
circleOffQuads source =
  zipWith nudge (circleCocircularQuads source) (stream 0x5d2e_8a47_c1f9_63b0)

-- The hexagonal lattice as binary64: x exact, y a rounded multiple of
-- sqrt 3 / 2. No four of its points are exactly cocircular in binary64, so
-- these are the near-cocircular quadrilaterals a Delaunay flip meets.
hexPoint :: Int -> Int -> (Double, Double)
hexPoint i j = (fromIntegral i + fromIntegral j / 2, fromIntegral j * (sqrt 3 / 2))

-- Four of the six lattice points around a lattice point: the corners of a
-- lattice hexagon, cocircular up to the rounding of the ordinate.
hexHexagonQuads :: [Word64] -> [Quad]
hexHexagonQuads = map hexagon . chunks 4
 where
  hexagon [iWord, jWord, skipWord, orderWord] =
    let i = modulo iWord 101 - 50
        j = modulo jWord 101 - 50
        ring = [(i + 1, j), (i, j + 1), (i - 1, j + 1), (i - 1, j), (i, j - 1), (i + 1, j - 1)]
        skipped = modulo skipWord 6
        kept = [corner | (position, corner) <- zip [0 :: Int ..] ring, position /= skipped, position /= (skipped + 3) `mod` 6]
     in permuted orderWord (map (uncurry hexPoint) kept)
  hexagon _ = error "hexHexagonQuads"

hexRandomQuads :: [Word64] -> [Quad]
hexRandomQuads = map (quadOf . concatMap coordinate . pairs) . chunks 8
 where
  pairs :: [Word64] -> [(Word64, Word64)]
  pairs (i : j : rest) = (i, j) : pairs rest
  pairs _ = []
  coordinate :: (Word64, Word64) -> [Double]
  coordinate (iWord, jWord) =
    let (x, y) = hexPoint (modulo iWord 101 - 50) (modulo jWord 101 - 50)
     in [x, y]

-- A cocircular grid quadrilateral with one nonzero coordinate moved by a
-- single ulp: the determinant is tiny against the magnitudes it is built
-- from, which is where a sign is hardest to resolve.
ulpOffQuads :: [Word64] -> [Quad]
ulpOffQuads source =
  zipWith ulpNudge (gridCocircularQuads source) (stream 0x8b3f_6e1a_d47c_2905)

ulpNudge :: Quad -> Word64 -> Quad
ulpNudge quad@(Quad ax ay bx by cx cy dx dy) word =
  let coordinates = [ax, ay, bx, by, cx, cy, dx, dy]
      start = modulo word 8
      candidates = [index | offset <- [0 .. 7], let index = (start + offset) `mod` 8, coordinates !! index /= 0]
      step value
        | even (word `shiftR` 3) = castWord64ToDouble (castDoubleToWord64 value + 1)
        | otherwise = castWord64ToDouble (castDoubleToWord64 value - 1)
   in case candidates of
        chosen : _ ->
          quadOf [if index == chosen then step value else value | (index, value) <- zip [0 :: Int ..] coordinates]
        [] -> quad

-- Repeated points, collinear triples, zeros of both signs, and mixtures of
-- those with ordinary coordinates.
degenerateQuads :: [Word64] -> [Quad]
degenerateQuads = map shape . chunks 9
 where
  shape (selector : rest) =
    case quadOf (map signed rest) of
      Quad ax ay bx by cx cy dx dy ->
        case modulo selector 6 of
          0 -> Quad ax ay ax ay cx cy dx dy
          1 -> Quad ax ay bx by cx cy ax ay
          2 -> Quad ax ay (ax + 1) (ay + 1) (ax + 2) (ay + 2) dx dy
          3 -> Quad 0 0 0 negativeZero negativeZero 0 dx dy
          4 -> Quad ax 0 bx 0 cx 0 dx 0
          _ -> Quad ax ay bx by cx cy negativeZero negativeZero
  shape _ = error "degenerateQuads"
  negativeZero = -0.0 :: Double