moonlight-planar-1.1.0.0: src-serialize/Moonlight/Planar/Serialization.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | The versioned binary surface: encode a triangulation to bytes and read it
-- back. Decoding refuses a payload whose format version or coordinate encoding this
-- build does not own, rather than reinterpreting it.
module Moonlight.Planar.Serialization
( DecodingBudget (..)
, TrustedPayloadDecoders
, trustedBinaryPayloadDecoders
, SerializedCountKind (..)
, SerializationError (..)
, serializationVersion
, encodeTriangulation
, decodeTriangulation
) where
import Control.Monad (unless, when)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Except (ExceptT, runExceptT, throwE)
import Data.Binary (Binary (..))
import Data.Binary.Get
( Get
, bytesRead
, getDoublebe
, getWord16be
, getWord32be
, getWord64be
, getWord8
, runGetOrFail
)
import Data.Binary.Put
( putDoublebe
, putWord16be
, putWord32be
, putWord64be
, putWord8
, runPut
)
import qualified Data.ByteString.Lazy as BL
import Data.Foldable (traverse_)
import qualified Data.IntSet as IntSet
import Data.Int (Int64)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Proxy (Proxy (..))
import qualified Data.Set as Set
import qualified Data.Vector as V
import qualified Data.Vector.Unboxed as U
import Data.Word (Word16, Word64, Word8)
import Moonlight.Planar.Internal.BoxedPaged
( BoxedFill (..)
, boxedFill
, boxedFoldr
, boxedFromVector
)
import Moonlight.Planar.Internal.Validation (canonicalAdmission)
import Moonlight.Planar.Internal.Paged (fromLocalVector, fromVector, pagedFoldr, pagedIFoldr, pagedLength)
import Moonlight.Planar.Internal.PackedIndex (indexLimit)
import Moonlight.Planar.Internal.PointIndex (buildPointIndex)
import Moonlight.Planar.Internal.HandleDefs
import Moonlight.Planar.Internal.Representation
import Moonlight.Planar.Internal.Types
import Moonlight.Planar.Point (Point (..), PointValidationError (..))
import Moonlight.Planar.Scalar (canonicalScalarZero)
import Moonlight.Planar.Point (mkQueryPoint)
import Moonlight.Planar.Validation (validateTriangulation)
instance Binary (Point) where
put (Point x y) = putDoublebe x >> putDoublebe y
get = Point <$> getDoublebe <*> getDoublebe
-- | The finite resource envelope admitted by the canonical decoder. The byte
-- budget bounds the complete input before parsing; the element budget bounds
-- the total number of library-owned serialized section elements before any
-- section is allocated.
data DecodingBudget = DecodingBudget
{ decodingMaximumInputBytes :: !Word64
, decodingMaximumSectionElements :: !Word64
}
deriving stock (Eq, Show)
-- | Evidence that the caller accepts the internal resource behavior of all
-- four payload decoders. The structural budget governs only containers owned
-- by this module; executable payload decoders require this separate trust law.
data TrustedPayloadDecoders vertex directed undirected face where
TrustedBinaryPayloadDecoders
:: ( Binary vertex
, Binary directed
, Binary undirected
, Binary face
)
=> TrustedPayloadDecoders vertex directed undirected face
-- | Explicitly trust the selected 'Binary' payload instances. This witness is
-- required because arbitrary instances may allocate independently of input
-- bytes; constructing it declares that the caller has audited that behavior.
trustedBinaryPayloadDecoders
:: ( Binary vertex
, Binary directed
, Binary undirected
, Binary face
)
=> TrustedPayloadDecoders vertex directed undirected face
trustedBinaryPayloadDecoders = TrustedBinaryPayloadDecoders
-- | The structural count whose encoded value was outside the resident index
-- or allocation domain.
data SerializedCountKind
= SerializedVertexCount
| SerializedDirectedEdgeCount
| SerializedFaceCount
| SerializedConstraintCount
deriving stock (Eq, Ord, Show)
-- | Every way serialization refuses, each naming its witness.
data SerializationError
= BinaryDecodeFailure !Int64 !String
| TrailingBytes !Int64
| InvalidFormatMagic !Word64
| UnsupportedFormatVersion !Word16
| ConstraintModeTagMismatch !Word8 !Word8
| CoordinateEncodingTagMismatch !Word8 !Word8
| InputByteBudgetExceeded !Word64 !Word64
| DecodedSectionBudgetExceeded !Word64 !Word64
| EncodedCountExceedsInt !SerializedCountKind !Word64
| EncodedCountExceedsPackedIndex !SerializedCountKind !Word64 !Word64
| SerializedDirectedEdgeCountOdd !Word64
| SerializedConstraintCountExceedsEdges !Word64 !Word64
| SerializedPlanarCardinalityMismatch !Word64 !Word64 !Word64
| SerializedFixedBodyTooShort !Word64 !Word64
| SerializedMissingOuterFace
| InvalidSerializedPoint {-# UNPACK #-} !Int !PointValidationError
| NonCanonicalSerializedConstraintFlag !UndirectedEdgeId !Word8
| SerializedConstraintCountMismatch {-# UNPACK #-} !Int {-# UNPACK #-} !Int
| UnconstrainedSerializedConstraints {-# UNPACK #-} !Int
| DuplicateSerializedCoordinates {-# UNPACK #-} !Int {-# UNPACK #-} !Int
| DecodedInvariantViolations !(NonEmpty InvariantViolation)
deriving stock (Eq, Show)
type Decoder = ExceptT SerializationError Get
-- | The envelope version this module writes.
serializationVersion :: Word16
serializationVersion = 6
formatMagic :: Word64
formatMagic = 0x5350414445485307 -- "SPADEHS" + canonical geometry-owned format family
binary64EncodingTag :: Word8
binary64EncodingTag = 2
-- | Write the versioned binary envelope.
encodeTriangulation
:: forall mode vertex directed undirected face. (KnownConstraintMode mode, Binary vertex, Binary directed, Binary undirected, Binary face)
=> Triangulation mode vertex directed undirected face
-> BL.ByteString
encodeTriangulation triangulation = runPut $ do
putWord64be formatMagic
putWord16be serializationVersion
putWord8 (modeTag (constraintModeValue (modeProxy triangulation)))
putWord8 binary64EncodingTag
let ElementDefaults directedDefault undirectedDefault faceDefault = authoringElementDefaults triangulation
vertexDefault = case boxedFill (triVertexData triangulation) of
NoFill -> Nothing
Fill value -> Just value
vertexCount = pagedLength (triPointX triangulation)
directedEdgeCount = pagedLength (triHalfTopology triangulation) `quot` 4
faceCount = pagedLength (triFaceEdge triangulation)
-- Version 6 commits every structural count in one prefix. The decoder can
-- prove their relationships and resource bounds before defaults, payloads,
-- or section bodies are evaluated.
putWord64be (fromIntegral vertexCount)
putWord64be (fromIntegral directedEdgeCount)
putWord64be (fromIntegral faceCount)
putWord64be (fromIntegral (triConstraintCount triangulation))
put directedDefault
put undirectedDefault
put faceDefault
-- Geometry and payloads are independent components. Persist the authoritative
-- coordinate pages rather than attempting to recover them from annotations.
pagedFoldr ((>>) . putDoublebe) (pure ()) (triPointX triangulation)
pagedFoldr ((>>) . putDoublebe) (pure ()) (triPointY triangulation)
put vertexDefault
boxedFoldr ((>>) . put) (pure ()) (triVertexData triangulation)
pagedFoldr ((>>) . putWord32be) (pure ()) (triVertexOut triangulation)
-- The wire format stores the four topology planes separately; the interleaved
-- arena is a resident layout, not a serialization concern.
let plane field =
pagedIFoldr
(\index value rest -> if index `rem` 4 == field then putWord32be value >> rest else rest)
(pure ())
(triHalfTopology triangulation)
plane 0
plane 1
plane 2
plane 3
boxedFoldr ((>>) . put) (pure ()) (triDirectedData triangulation)
boxedFoldr ((>>) . put) (pure ()) (triUndirectedData triangulation)
pagedFoldr ((>>) . putWord32be) (pure ()) (triFaceEdge triangulation)
boxedFoldr ((>>) . put) (pure ()) (triFaceData triangulation)
pagedFoldr ((>>) . putWord8) (pure ()) (triConstraint triangulation)
-- | Decode one exact, versioned finite DCEL inside an explicit resource
-- envelope. Structural counts are read and reconciled before any default,
-- payload, or section body is decoded. Coordinate uniqueness and the complete
-- topology, geometry, and Delaunay/CDT invariants are checked before the opaque
-- value is returned.
--
-- The budget bounds the input and the containers owned by this module. The
-- required 'TrustedPayloadDecoders' witness separately records the caller's
-- decision that every selected payload decoder is internally resource-safe.
decodeTriangulation
:: forall mode vertex directed undirected face.
KnownConstraintMode mode
=> DecodingBudget
-> TrustedPayloadDecoders vertex directed undirected face
-> BL.ByteString
-> Either SerializationError (Triangulation mode vertex directed undirected face)
decodeTriangulation budget TrustedBinaryPayloadDecoders bytes
| inputByteCount > decodingMaximumInputBytes budget =
Left
( InputByteBudgetExceeded
inputByteCount
(decodingMaximumInputBytes budget)
)
| otherwise =
case runGetOrFail (runExceptT getTriangulation) bytes of
Left (_, offset, message) -> Left (BinaryDecodeFailure offset message)
Right (_, _, Left failure) -> Left failure
Right (trailing, _, Right triangulation)
| not (BL.null trailing) -> Left (TrailingBytes (BL.length trailing))
| otherwise ->
case validateTriangulation triangulation of
[] -> Right triangulation{triCanonical = canonicalAdmission triangulation}
firstViolation : remainingViolations ->
Left (DecodedInvariantViolations (firstViolation :| remainingViolations))
where
inputByteCount = fromIntegral (BL.length bytes)
getTriangulation :: Decoder (Triangulation mode vertex directed undirected face)
getTriangulation = do
magic <- lift getWord64be
unless (magic == formatMagic) (throwE (InvalidFormatMagic magic))
version <- lift getWord16be
unless (version == serializationVersion) (throwE (UnsupportedFormatVersion version))
encodedMode <- lift getWord8
let expectedMode = modeTag (constraintModeValue (Proxy :: Proxy mode))
unless (encodedMode == expectedMode) (throwE (ConstraintModeTagMismatch expectedMode encodedMode))
encodedScalar <- lift getWord8
let expectedScalar = binary64EncodingTag
unless (encodedScalar == expectedScalar) (throwE (CoordinateEncodingTagMismatch expectedScalar encodedScalar))
encodedVertexCount <- lift getWord64be
encodedDirectedEdgeCount <- lift getWord64be
encodedFaceCount <- lift getWord64be
encodedConstraintCount <- lift getWord64be
validateStructuralPrefix
budget
encodedVertexCount
encodedDirectedEdgeCount
encodedFaceCount
encodedConstraintCount
prefixByteCount <- fromIntegral <$> lift bytesRead
let bodyByteCount = inputByteCount - prefixByteCount
minimumBodyByteCount =
minimumFixedBodyBytes
encodedVertexCount
encodedDirectedEdgeCount
encodedFaceCount
unless (bodyByteCount >= minimumBodyByteCount) $
throwE (SerializedFixedBodyTooShort bodyByteCount minimumBodyByteCount)
let vertexCount = fromIntegral encodedVertexCount
halfCount = fromIntegral encodedDirectedEdgeCount
edgeCount = halfCount `quot` 2
faceCount = fromIntegral encodedFaceCount
cachedConstraintCount = fromIntegral encodedConstraintCount
defaults <- ElementDefaults <$> lift get <*> lift get <*> lift get
pointXs <- U.replicateM vertexCount (canonicalScalarZero <$> lift getDoublebe)
pointYs <- U.replicateM vertexCount (canonicalScalarZero <$> lift getDoublebe)
vertexDefault <- lift get
vertexDataVector <- V.replicateM vertexCount (lift get)
vertexOut <- U.replicateM vertexCount (lift getWord32be)
halfOrigin <- U.replicateM halfCount (lift getWord32be)
halfNext <- U.replicateM halfCount (lift getWord32be)
halfPrev <- U.replicateM halfCount (lift getWord32be)
halfFace <- U.replicateM halfCount (lift getWord32be)
directedDataVector <- V.replicateM halfCount (lift get)
undirectedDataVector <- V.replicateM edgeCount (lift get)
faceEdge <- U.replicateM faceCount (lift getWord32be)
faceDataVector <- V.replicateM faceCount (lift get)
constraints <- U.replicateM edgeCount (lift getWord8)
let points = V.generate vertexCount (\index -> Point (pointXs U.! index) (pointYs U.! index))
traverse_ (uncurry validateStoredPoint) (V.indexed points)
case U.ifoldr (\index flag found -> if flag /= 0 && flag /= 1 then Just (index, flag) else found) Nothing constraints of
Nothing -> pure ()
Just (index, flag) ->
throwE
( NonCanonicalSerializedConstraintFlag
(UndirectedEdgeId (fromIntegral index))
flag
)
let actualConstraintCount = U.foldl' (\count flag -> if flag == 1 then count + 1 else count) 0 constraints
unless (cachedConstraintCount == actualConstraintCount) (throwE (SerializedConstraintCountMismatch cachedConstraintCount actualConstraintCount))
when (expectedMode == 0 && actualConstraintCount /= 0) (throwE (UnconstrainedSerializedConstraints actualConstraintCount))
let distinctPointCount = Set.size (V.foldl' (flip Set.insert) Set.empty points)
unless (distinctPointCount == vertexCount) (throwE (DuplicateSerializedCoordinates vertexCount distinctPointCount))
let pointXStore = fromLocalVector 0 pointXs
pointYStore = fromLocalVector 0 pointYs
topologyStore =
fromVector maxBound $
U.generate (4 * halfCount) $ \slot ->
let (edge, field) = slot `quotRem` 4
in case field of
0 -> halfOrigin U.! edge
1 -> halfNext U.! edge
2 -> halfPrev U.! edge
_ -> halfFace U.! edge
constraintEdgeIndex =
U.ifoldl'
(\edges index flag ->
if flag == 1 then IntSet.insert index edges else edges
)
IntSet.empty
constraints
-- Canonical admission reads topology through unchecked packed indices.
-- Keep this value unadmitted until outer validation has discharged them.
let decoded =
Triangulation
{ triPointX = pointXStore
, triPointY = pointYStore
, triPointIndex = buildPointIndex pointXStore pointYStore
, triVertexOut = fromLocalVector maxBound vertexOut
, triVertexData = boxedFromVector (maybe NoFill Fill vertexDefault) vertexDataVector
, triHalfTopology = topologyStore
, triDirectedData = boxedFromVector (Fill (defaultDirectedEdgeData defaults)) directedDataVector
, triUndirectedData = boxedFromVector (Fill (defaultUndirectedEdgeData defaults)) undirectedDataVector
, triFaceEdge = fromLocalVector maxBound faceEdge
, triFaceData = boxedFromVector (Fill (defaultFaceData defaults)) faceDataVector
, triConstraint = fromVector 0 constraints
, triConstraintCount = cachedConstraintCount
, triConstraintEdges = constraintEdgeIndex
, triSeamFrontier = Nothing
, triCanonical = CanonicalUnknown
}
pure decoded
validateStoredPoint :: Int -> Point -> Decoder ()
validateStoredPoint index point =
case mkQueryPoint point of
Left failure -> throwE (InvalidSerializedPoint index failure)
Right _ -> pure ()
modeProxy :: Triangulation mode vertex directed undirected face -> Proxy mode
modeProxy _ = Proxy
modeTag :: ConstraintMode -> Word8
modeTag Unconstrained = 0
modeTag Constrained = 1
validateStructuralPrefix
:: DecodingBudget
-> Word64
-> Word64
-> Word64
-> Word64
-> Decoder ()
validateStructuralPrefix budget vertexCount directedEdgeCount faceCount constraintCount = do
traverse_
(uncurry validateEncodedCount)
[ (SerializedVertexCount, vertexCount)
, (SerializedDirectedEdgeCount, directedEdgeCount)
, (SerializedFaceCount, faceCount)
, (SerializedConstraintCount, constraintCount)
]
unless (even directedEdgeCount) $
throwE (SerializedDirectedEdgeCountOdd directedEdgeCount)
unless (faceCount >= 1) (throwE SerializedMissingOuterFace)
let undirectedEdgeCount = directedEdgeCount `quot` 2
unless (constraintCount <= undirectedEdgeCount) $
throwE
( SerializedConstraintCountExceedsEdges
constraintCount
undirectedEdgeCount
)
unless
(planarCardinalityHolds vertexCount directedEdgeCount faceCount)
( throwE
( SerializedPlanarCardinalityMismatch
vertexCount
directedEdgeCount
faceCount
)
)
let sectionElements =
4 * vertexCount
+ 6 * directedEdgeCount
+ 2 * faceCount
maximumElements = decodingMaximumSectionElements budget
when (sectionElements > maximumElements) $
throwE (DecodedSectionBudgetExceeded sectionElements maximumElements)
validateEncodedCount :: SerializedCountKind -> Word64 -> Decoder ()
validateEncodedCount kind count = do
when (count > fromIntegral (maxBound :: Int)) $
throwE (EncodedCountExceedsInt kind count)
when (count > maximumPackedElementCount) $
throwE
( EncodedCountExceedsPackedIndex
kind
count
maximumPackedElementCount
)
-- Each resident handle is a Word32 with @maxBound@ withheld as the optional
-- no-index marker. A count may include every remaining representable handle.
maximumPackedElementCount :: Word64
maximumPackedElementCount = fromIntegral indexLimit + 1
-- Every resident triangulation is a connected planar straight-line graph.
-- Empty input has only the outer face; a bounded-face-free nonempty graph is the
-- collinear chain; otherwise Euler's law determines the edge count. All
-- arithmetic is safe after 'validateEncodedCount' bounds each term to the
-- packed-index domain.
planarCardinalityHolds :: Word64 -> Word64 -> Word64 -> Bool
planarCardinalityHolds vertexCount directedEdgeCount faceCount
| vertexCount == 0 = directedEdgeCount == 0 && faceCount == 1
| faceCount == 1 = directedEdgeCount == 2 * (vertexCount - 1)
| vertexCount < 3 = False
| otherwise =
directedEdgeCount == 2 * (vertexCount + faceCount - 2)
-- The packed-index proof above bounds every term far below Word64 overflow.
-- Payloads and defaults have no format-level lower bound because lawful
-- 'Binary' decoders such as that for @()@ may consume zero bytes.
minimumFixedBodyBytes :: Word64 -> Word64 -> Word64 -> Word64
minimumFixedBodyBytes vertexCount directedEdgeCount faceCount =
20 * vertexCount
+ 16 * directedEdgeCount
+ 4 * faceCount
+ directedEdgeCount `quot` 2