packages feed

moonlight-planar-1.2.0.0: src-dcel/Moonlight/Planar/Internal/Length.hs

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}

-- | Certified Euclidean length arithmetic, independent of geometry: finite
-- sums of rational multiples of square roots in radical normal form, their
-- outward rational enclosures at an admitted precision, and the directed
-- binary64 projection of those enclosures. Region valuation and curve
-- measurement share this one owner.
module Moonlight.Planar.Internal.Length
  ( ExactLengthTerm
  , lengthCoefficient
  , lengthRadicand
  , ExactLengthExpression
  , exactLengthTerms
  , normalizeLengthContributions
  , scaleLengthExpression
  , RadicalPrecision
  , RadicalPrecisionError (..)
  , radicalPrecision
  , radicalPrecisionBits
  , publicationPrecision
  , LengthEnclosure
  , lengthEnclosureLower
  , lengthEnclosureUpper
  , lengthEnclosureWidth
  , enclosureBetween
  , LengthError (..)
  , squareRootEnclosure
  , expressionEnclosure
  , euclideanLengthEnclosure
  , CertifiedInterval (..)
  , certifiedInterval
  , ExactLengthMeasurement
  , exactLengthExpression
  , exactLengthBounds
  , measureLengthExpression
  ) where

import Control.DeepSeq (NFData)
import Data.Bits (shiftL)
import Data.Foldable (foldlM)
import qualified Data.IntSet as IntSet
import qualified Data.List as List
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe)
import qualified Data.Ratio as Ratio
import Data.Word (Word64)
import GHC.Float (castDoubleToWord64, castWord64ToDouble)
import GHC.Generics (Generic)
import Moonlight.Planar.Internal.Dyadic (integerBitLength)
import Moonlight.Planar.Internal.ExactRational
  ( ExactRational
  , exactRationalDenominator
  , exactRationalFromDyadic
  , exactRationalFromFiniteDouble
  , exactRationalFromNormalizedRatio
  , exactRationalNumerator
  , exactSignum
  , PositiveExact
  , positiveExactValue
  , positiveOne
  )

data ExactLengthTerm = ExactLengthTerm
  { lengthCoefficient :: !ExactRational
  , lengthRadicand :: !Integer
  }
  deriving stock (Eq, Ord, Show, Generic)
  deriving anyclass (NFData)

-- | A normalized sum of rational coefficients times square roots of integer
-- radicands. No two radicands differ by a rational square, so equal lengths
-- are one term and rational length is the radicand-1 term; each radicand is
-- reduced by the square factors of the primes below 64 and by a perfect-square
-- cofactor. A squared prime above that bound inside a non-square cofactor is
-- beyond factoring-free reach, so the presentation is canonical within an
-- expression and across expressions only up to such factors; there is no
-- 'Eq' instance for that reason.
newtype ExactLengthExpression = ExactLengthExpression [ExactLengthTerm]
  deriving stock (Show, Generic)
  deriving anyclass (NFData)

exactLengthTerms :: ExactLengthExpression -> [ExactLengthTerm]
exactLengthTerms (ExactLengthExpression terms) = terms

-- | Binary64 endpoints rounded outward from a rational enclosure.
data CertifiedInterval = CertifiedInterval
  { intervalLower :: !Double
  , intervalUpper :: !Double
  }
  deriving stock (Eq, Ord, Show, Generic)
  deriving anyclass (NFData)

data ExactLengthMeasurement = ExactLengthMeasurement
  { exactLengthExpression :: !ExactLengthExpression
  , exactLengthBounds :: !CertifiedInterval
  }
  deriving stock (Show, Generic)
  deriving anyclass (NFData)

-- | Fractional bits of a square-root enclosure: each root is bracketed by
-- consecutive multiples of @2^-bits@, so its width is at most @2^-bits@.
newtype RadicalPrecision = RadicalPrecision Int
  deriving stock (Eq, Ord, Show, Generic)
  deriving anyclass (NFData)

-- | A root is bracketed by shifting its radicand left by twice the precision,
-- so the doubled count must itself be a representable shift.
data RadicalPrecisionError
  = NonPositiveRadicalPrecision !Int
  | UnrepresentableRadicalPrecision !Int
  deriving stock (Eq, Ord, Show, Generic)
  deriving anyclass (NFData)

radicalPrecision :: Int -> Either RadicalPrecisionError RadicalPrecision
radicalPrecision bits
  | bits <= 0 = Left (NonPositiveRadicalPrecision bits)
  | bits > maxBound `quot` 2 = Left (UnrepresentableRadicalPrecision bits)
  | otherwise = Right (RadicalPrecision bits)

radicalPrecisionBits :: RadicalPrecision -> Int
radicalPrecisionBits (RadicalPrecision bits) = bits

-- | 128 bits: far below binary64 resolution for any length a double can
-- represent, so directed publication, not the rational enclosure, dominates
-- the width of a 'CertifiedInterval'.
publicationPrecision :: RadicalPrecision
publicationPrecision = RadicalPrecision 128

-- | An ordered pair of nonnegative rationals, @0 <= lower <= upper@. The sum
-- of enclosures encloses the sum of their values.
data LengthEnclosure = LengthEnclosure !ExactRational !ExactRational
  deriving stock (Eq, Show, Generic)
  deriving anyclass (NFData)

instance Semigroup LengthEnclosure where
  LengthEnclosure a b <> LengthEnclosure c d = LengthEnclosure (a + c) (b + d)

instance Monoid LengthEnclosure where
  mempty = LengthEnclosure 0 0

lengthEnclosureLower :: LengthEnclosure -> ExactRational
lengthEnclosureLower (LengthEnclosure lower _) = lower

lengthEnclosureUpper :: LengthEnclosure -> ExactRational
lengthEnclosureUpper (LengthEnclosure _ upper) = upper

lengthEnclosureWidth :: LengthEnclosure -> ExactRational
lengthEnclosureWidth (LengthEnclosure lower upper) = upper - lower

-- | The first's lower bound to the second's upper bound: it encloses every
-- value lying between the first's value and the second's whenever the first
-- is at most the second, as a chord is at most its control polygon. The
-- endpoints are ordered for any pair.
enclosureBetween :: LengthEnclosure -> LengthEnclosure -> LengthEnclosure
enclosureBetween (LengthEnclosure a _) (LengthEnclosure _ d) = LengthEnclosure (min a d) (max a d)

-- | A negative square has no real root.
newtype LengthError = LengthNegativeSquare ExactRational
  deriving stock (Eq, Show, Generic)
  deriving anyclass (NFData)

-- | Positive coefficients stay positive through merging, whose scales are
-- positive integers, so every normalized term has a positive coefficient.
-- This and 'scaleLengthExpression' are the only expression builders, which is
-- the invariant 'expressionEnclosure' relies on.
normalizeLengthContributions
  :: Foldable collection
  => (value -> (PositiveExact, ExactRational))
  -> collection value
  -> ExactLengthExpression
normalizeLengthContributions contribution =
  normalizeSquareCoefficients . List.foldl' accumulateContribution Map.empty
 where
  accumulateContribution coefficients value =
    let (coefficient, square) = contribution value
     in Map.insertWith (+) square (positiveExactValue coefficient) coefficients
-- Only the per-contribution fold is inlined, so each caller's contribution
-- fuses into it and no pair is built per element; the class merging below is
-- shared out of line.
{-# INLINE normalizeLengthContributions #-}

-- | Merge per-square coefficients into radical classes.
normalizeSquareCoefficients :: Map.Map ExactRational ExactRational -> ExactLengthExpression
normalizeSquareCoefficients coefficientsBySquare =
  ExactLengthExpression
    [ ExactLengthTerm coefficient radicand
    | (radicand, coefficient) <- List.sortOn fst (concat (Map.elems classes))
    ]
 where
  classes =
    Map.foldlWithKey' accumulateRadical Map.empty coefficientsBySquare
  accumulateRadical buckets square coefficient =
    let reduced = reduceRadicand square
     in if radicalRadicand reduced == 0
          then buckets
          else
            Map.alter
              ( Just
                  . mergeRadical (coefficient * radicalScale reduced) (radicalRadicand reduced)
                  . fromMaybe []
              )
              (radicalClassKey reduced)
              buckets

data ReducedRadical = ReducedRadical
  { radicalScale :: !ExactRational
  , radicalRadicand :: !Integer
  , radicalClassKey :: !Word64
  }

-- | Write √(n/d) as (1/d)·√(n·d), fold the even part of each trial prime's
-- multiplicity and a perfect-square cofactor into the scale, and key the
-- radicand by what those primes observe of its square class: the parity of
-- the multiplicity and the quadratic character of the prime-free part, both
-- invariant under multiplication by rational squares.
reduceRadicand :: ExactRational -> ReducedRadical
reduceRadicand square
  | radicand <= 0 = ReducedRadical inverseDenominator radicand 0
  | otherwise =
      let (cofactor, reduced) = List.foldl' stripPrime (radicand, initial) radicalPrimes
          root = integerSquareRoot cofactor
       in if root * root == cofactor
            then reduced {radicalScale = radicalScale reduced * fromInteger root}
            else reduced {radicalRadicand = radicalRadicand reduced * cofactor}
 where
  denominator = exactRationalDenominator square
  radicand = exactRationalNumerator square * denominator
  inverseDenominator = exactRationalFromNormalizedRatio (1 Ratio.% denominator)
  initial = ReducedRadical inverseDenominator 1 0
  stripPrime (remaining, reduced) (prime, residues) =
    let (multiplicity, rest) = primeMultiplicity prime remaining
        (halfPower, parity) = multiplicity `divMod` 2
        character = squareClassCharacter prime residues rest
     in ( rest
        , ReducedRadical
            { radicalScale = radicalScale reduced * fromInteger (prime ^ halfPower)
            , radicalRadicand = radicalRadicand reduced * prime ^ parity
            , radicalClassKey = radicalClassKey reduced * 8 + fromIntegral (parity * 4 + character)
            }
        )

radicalPrimes :: [(Integer, IntSet.IntSet)]
radicalPrimes =
  [ (toInteger prime, IntSet.fromList [(x * x) `mod` prime | x <- [1 .. prime - 1]])
  | prime <- [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61 :: Int]
  ]

primeMultiplicity :: Integer -> Integer -> (Int, Integer)
primeMultiplicity prime = go 0
 where
  go :: Int -> Integer -> (Int, Integer)
  go !count value =
    case value `quotRem` prime of
      (quotient, 0) -> go (count + 1) quotient
      _ -> (count, value)

squareClassCharacter :: Integer -> IntSet.IntSet -> Integer -> Int
squareClassCharacter 2 _ rest = fromInteger ((rest `mod` 8) `div` 2)
squareClassCharacter prime residues rest =
  if IntSet.member (fromInteger (rest `mod` prime)) residues then 1 else 0

-- | Fold a term into its square class: the product of two radicands of one
-- class is a perfect square, whose root exposes the rational square relating
-- them, so both fold onto their common divisor with integer scales.
mergeRadical
  :: ExactRational
  -> Integer
  -> [(Integer, ExactRational)]
  -> [(Integer, ExactRational)]
mergeRadical coefficient radicand = go
 where
  go [] = [(radicand, coefficient)]
  go ((kernel, total) : rest)
    | pairRoot * pairRoot == pairProduct =
        let common = gcd pairRoot kernel
            newScale = pairRoot `div` common
            oldScale = kernel `div` common
         in ( kernel `div` (oldScale * oldScale)
            , total * fromInteger oldScale + coefficient * fromInteger newScale
            )
              : rest
    | otherwise = (kernel, total) : go rest
   where
    pairProduct = radicand * kernel
    pairRoot = integerSquareRoot pairProduct

scaleLengthExpression
  :: PositiveExact
  -> ExactLengthExpression
  -> ExactLengthExpression
scaleLengthExpression scalar (ExactLengthExpression terms) =
  let exactScalar = positiveExactValue scalar
   in ExactLengthExpression
        [ term
            { lengthCoefficient =
                exactScalar * lengthCoefficient term
            }
        | term <- terms
        ]

-- | The root of a nonnegative rational between consecutive multiples of
-- @2^-bits@; an exact dyadic root has zero width.
squareRootEnclosure
  :: RadicalPrecision
  -> ExactRational
  -> Either LengthError LengthEnclosure
squareRootEnclosure (RadicalPrecision bits) value =
  case exactSignum value of
    LT -> Left (LengthNegativeSquare value)
    _ -> Right (uncurry LengthEnclosure (rootBounds bits value))

-- | Consecutive multiples of @2^-bits@ around the root of a nonnegative value.
rootBounds :: Int -> ExactRational -> (ExactRational, ExactRational)
rootBounds bits value =
  let numerator = exactRationalNumerator value
      denominator = exactRationalDenominator value
      scaledNumerator = numerator `shiftL` (2 * bits)
      root = integerSquareRoot (scaledNumerator `div` denominator)
      exact = root * root * denominator == scaledNumerator
      dyadicPower = negate bits
   in ( exactRationalFromDyadic root dyadicPower
      , exactRationalFromDyadic (if exact then root else root + 1) dyadicPower
      )

-- | Outward enclosure of the expression's value. Coefficients are positive by
-- construction, so each term scales its root enclosure without reordering; a
-- negative radicand, from a negative squared length, is refused.
expressionEnclosure
  :: RadicalPrecision
  -> ExactLengthExpression
  -> Either LengthError LengthEnclosure
expressionEnclosure precision (ExactLengthExpression terms) =
  foldlM addTerm mempty terms
 where
  addTerm (LengthEnclosure lower upper) (ExactLengthTerm coefficient radicand) = do
    LengthEnclosure lowerRoot upperRoot <-
      squareRootEnclosure precision (fromInteger radicand)
    pure (LengthEnclosure (lower + coefficient * lowerRoot) (upper + coefficient * upperRoot))

-- | Outward enclosure of the summed Euclidean norms of rational displacements,
-- through the radical normal form. Every contribution has coefficient one and
-- a nonnegative square, so every normalized term has a positive coefficient
-- and a positive radicand, and the enclosure needs no refusal. Each term's
-- endpoints are rounded outward onto multiples of @2^-bits@, so sums of these
-- enclosures stay on that grid instead of accumulating the coefficients'
-- denominators.
euclideanLengthEnclosure
  :: RadicalPrecision
  -> [(ExactRational, ExactRational)]
  -> LengthEnclosure
euclideanLengthEnclosure (RadicalPrecision bits) displacements =
  foldMap termEnclosure
    (exactLengthTerms (normalizeLengthContributions (\(x, y) -> (positiveOne, x * x + y * y)) displacements))
 where
  termEnclosure (ExactLengthTerm coefficient radicand) =
    let (lower, upper) = rootBounds bits (fromInteger radicand)
     in LengthEnclosure (gridFloor (coefficient * lower)) (negate (gridFloor (negate (coefficient * upper))))
  gridFloor value = exactRationalFromDyadic
    ((exactRationalNumerator value `shiftL` bits) `div` exactRationalDenominator value) (negate bits)

-- | Directed binary64 publication of a rational enclosure.
certifiedInterval :: LengthEnclosure -> CertifiedInterval
certifiedInterval (LengthEnclosure lower upper) =
  CertifiedInterval
    { intervalLower = directedLowerDouble lower
    , intervalUpper = directedUpperDouble upper
    }

measureLengthExpression
  :: RadicalPrecision
  -> ExactLengthExpression
  -> Either LengthError ExactLengthMeasurement
measureLengthExpression precision expression =
  ExactLengthMeasurement expression . certifiedInterval
    <$> expressionEnclosure precision expression

integerSquareRoot :: Integer -> Integer
integerSquareRoot value
  | value < 2 = value
  | otherwise = descend initial
 where
  initial = 1 `shiftL` ((integerBitLength value + 1) `div` 2)
  descend estimate =
    let refined = (estimate + value `div` estimate) `div` 2
     in if refined >= estimate then estimate else descend refined

directedLowerDouble :: ExactRational -> Double
directedLowerDouble value =
  let candidate = rationalToDouble value
   in if isInfinite candidate
        then maximumFiniteDouble
        else
          if exactRationalFromFiniteDouble candidate <= value
            then candidate
            else previousPositiveDouble candidate

directedUpperDouble :: ExactRational -> Double
directedUpperDouble value =
  let candidate = rationalToDouble value
   in if isInfinite candidate
        || exactRationalFromFiniteDouble candidate >= value
        then candidate
        else nextPositiveDouble candidate

rationalToDouble :: ExactRational -> Double
rationalToDouble value =
  fromRational
    ( exactRationalNumerator value
        Ratio.% exactRationalDenominator value
    )

previousPositiveDouble :: Double -> Double
previousPositiveDouble value
  | value <= 0 = 0
  | otherwise = castWord64ToDouble (castDoubleToWord64 value - 1)

nextPositiveDouble :: Double -> Double
nextPositiveDouble value
  | value == 0 = castWord64ToDouble 1
  | otherwise = castWord64ToDouble (castDoubleToWord64 value + 1)

maximumFiniteDouble :: Double
maximumFiniteDouble = castWord64ToDouble 0x7fefffffffffffff