packages feed

moonlight-triangulation-0.1.0.0: src-dcel/Moonlight/Triangulation/Internal/PointIndex.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE NamedFieldPuns #-}

-- | The one derived identity index from canonical position hashes to the
-- vertices holding them. Geometry remains solely in the coordinate arenas:
-- both forms store handles only, and every hit is confirmed against those
-- authoritative coordinates. The persistent form follows a published mesh;
-- the open-addressed form is the denser ingress representation used while a
-- bulk load is still claiming its vertices.
module Moonlight.Triangulation.Internal.PointIndex
  ( PointIndex
  , emptyPointIndex
  , buildPointIndex
  , pointIndexCandidates
  , lookupPointIndex
  , insertPointIndex
  , removePointIndex
  , relocatePointIndex
  , MutablePointIndex
  , MutablePointIndexUpdate (..)
  , newMutablePointIndex
  , seedMutablePointIndex
  , lookupMutablePoint
  , removeMutablePoint
  , relocateMutablePoint
  , resolveMutablePoint
  ) where

import Control.DeepSeq (NFData (..))
import Control.Monad.ST (ST)
import Data.Bits (shiftR, xor, (.&.))
import qualified Data.IntMap.Strict as IntMap
import qualified Data.Vector.Unboxed.Mutable as MUV
import Data.Word (Word32, Word64)
import GHC.Float (castDoubleToWord64)
import Moonlight.Triangulation.Internal.Types (BuildError (PointIndexCapacityExhausted), Point (..))
import Moonlight.Triangulation.Internal.Paged
  ( Paged
  , pagedUnsafeIndex
  , toVector
  )
import Moonlight.Triangulation.Scalar (canonicalScalarZero)
import qualified Data.Vector.Unboxed as U

-- | Hash buckets containing only vertex handles. Callers confirm candidates
-- against the authoritative coordinate planes.
newtype PointIndex = PointIndex (IntMap.IntMap [Int])
  deriving stock (Eq, Show)

-- A point index is a memoized derived view. Forcing a triangulation forces the
-- geometry and topology that determine it, not a cache no read has demanded.
-- This is the same semantic boundary as omitting the cache from serialization.
instance NFData PointIndex where
  rnf _ = ()

emptyPointIndex :: PointIndex
emptyPointIndex = PointIndex IntMap.empty

-- | Derive an index from the authoritative structure-of-arrays geometry.
buildPointIndex :: Paged Double -> Paged Double -> PointIndex
buildPointIndex pointXs pointYs =
  U.ifoldl'
    (\index vertex x ->
       insertPointIndex x (pagedUnsafeIndex pointYs vertex) vertex index
    )
    emptyPointIndex
    (toVector pointXs)

-- | Candidate vertices sharing a position hash. Exact coordinate comparison
-- belongs to the caller that owns the coordinate reader.
pointIndexCandidates :: Double -> Double -> PointIndex -> [Int]
pointIndexCandidates x y (PointIndex buckets) =
  IntMap.findWithDefault [] (pointHash x y) buckets
{-# INLINE pointIndexCandidates #-}

-- | Resolve one exact position against the authoritative coordinate planes.
-- Hashes reject; coordinates prove. Keeping this here makes the persistent
-- removal and constraint schedules consume the same identity law as a mutable
-- session instead of paying a topological point-location walk for a key lookup.
lookupPointIndex
  :: Paged Double
  -> Paged Double
  -> PointIndex
  -> Point
  -> Maybe Int
lookupPointIndex pointXs pointYs pointIndex (Point x y) =
  foldr confirm Nothing (pointIndexCandidates x y pointIndex)
 where
  confirm candidate resolved
    | pagedUnsafeIndex pointXs candidate == x
        && pagedUnsafeIndex pointYs candidate == y = Just candidate
    | otherwise = resolved

insertPointIndex :: Double -> Double -> Int -> PointIndex -> PointIndex
insertPointIndex x y vertex (PointIndex buckets) =
  PointIndex (IntMap.insertWith (++) (pointHash x y) [vertex] buckets)
{-# INLINE insertPointIndex #-}

-- | Forget one proved handle without touching geometry. Empty collision
-- buckets disappear, so the index remains a derived finite view rather than a
-- history of retired sites.
removePointIndex :: Double -> Double -> Int -> PointIndex -> PointIndex
removePointIndex x y vertex (PointIndex buckets) =
  PointIndex (IntMap.update retainOthers (pointHash x y) buckets)
 where
  retainOthers candidates = case candidates of
    [candidate]
      | candidate == vertex -> Nothing
    _ ->
      case removeCandidate candidates of
        [] -> Nothing
        remaining -> Just remaining

  removeCandidate [] = []
  removeCandidate (candidate : remaining)
    | candidate == vertex = remaining
    | otherwise = candidate : removeCandidate remaining
{-# INLINE removePointIndex #-}

-- | Transport the tail vertex into the slot vacated by swap compaction.
-- Coordinate storage remains authoritative; this updates handles only.
relocatePointIndex
  :: Double
  -> Double
  -> Int
  -> Int
  -> PointIndex
  -> PointIndex
relocatePointIndex x y previousVertex currentVertex (PointIndex buckets) =
  PointIndex
    (IntMap.alter (Just . relocateCandidate . maybe [] id) (pointHash x y) buckets)
 where
  relocateCandidate [] = [currentVertex]
  relocateCandidate (candidate : remaining)
    | candidate == previousVertex = currentVertex : remaining
    | otherwise = candidate : relocateCandidate remaining
{-# INLINE relocatePointIndex #-}

-- The arena already holds every key, so a slot is the vertex that owns the
-- position and nothing else. Every lookup rechecks coordinates; a failed local
-- transport invalidates the table rather than letting it outlive that proof.
data MutablePointIndex s = MutablePointIndex
  { tableSlots :: !(MUV.MVector s Word32)
  , tableMask :: {-# UNPACK #-} !Int
  }

-- | A local cache transport either preserves the proof that every slot agrees
-- with the coordinate arenas, or explicitly gives that proof up. Callers must
-- fall back to the lazy immutable derivation after 'MutablePointIndexInvalidated';
-- they never publish a table whose handle correspondence was not established.
data MutablePointIndexUpdate
  = MutablePointIndexUpdated
  | MutablePointIndexInvalidated

vacant :: Word32
vacant = maxBound

-- | Size a table for a stated number of distinct positions.
newMutablePointIndex :: Int -> ST s (MutablePointIndex s)
newMutablePointIndex expected = do
  tableSlots <- MUV.replicate capacity vacant
  pure MutablePointIndex{tableSlots, tableMask = capacity - 1}
 where
  -- Two slots per key, rounded up to a power of two: linear probing stays in a
  -- short run and the mask stands in for a division.
  !capacity = grow 16
  !wanted = 2 * max 1 expected
  grow !size
    | size >= wanted = size
    | otherwise = grow (size * 2)

-- | Seed the open-addressed section from authoritative coordinates. This is
-- the shared ingress/batch operation: no point payload or parallel identity
-- store crosses the boundary.
seedMutablePointIndex
  :: MutablePointIndex s
  -> Int
  -> (Int -> ST s Double)
  -> (Int -> ST s Double)
  -> ST s (Either BuildError ())
seedMutablePointIndex table count readX readY = seed 0
 where
  seed !vertex
    | vertex >= count = pure (Right ())
    | otherwise = do
        x <- readX vertex
        y <- readY vertex
        claimed <- resolveMutablePoint table readX readY x y vertex
        case claimed of
          Left failure -> pure (Left failure)
          Right _ -> seed (vertex + 1)

-- | Look up a canonical position in the mutable section. A vacant slot proves
-- absence; all occupied candidates are confirmed against coordinate authority.
lookupMutablePoint
  :: MutablePointIndex s
  -> (Int -> ST s Double)
  -> (Int -> ST s Double)
  -> Double
  -> Double
  -> ST s (Maybe Int)
lookupMutablePoint MutablePointIndex{tableSlots, tableMask} readX readY x y =
  probe (fromIntegral (mixCoordinates x y) .&. tableMask) (tableMask + 1)
 where
  probe !slot !budget
    | budget <= 0 = pure Nothing
    | otherwise = do
        occupant <- MUV.unsafeRead tableSlots slot
        if occupant == vacant
          then pure Nothing
          else do
            heldX <- readX (fromIntegral occupant)
            heldY <- readY (fromIntegral occupant)
            if heldX == x && heldY == y
              then pure (Just (fromIntegral occupant))
              else probe ((slot + 1) .&. tableMask) (budget - 1)
{-# INLINE lookupMutablePoint #-}

-- | Forget a retired handle and repair its linear-probe cluster by backward
-- shifting only entries whose home run crosses the resulting hole. The reader
-- is needed for the surviving entries' homes; the retiring entry itself is
-- identified by handle because swap compaction may already have overwritten
-- its coordinate slot. An exhausted or absent proof invalidates the derived
-- cache instead of manufacturing a lookup result.
removeMutablePoint
  :: MutablePointIndex s
  -> (Int -> ST s Double)
  -> (Int -> ST s Double)
  -> Double
  -> Double
  -> Int
  -> ST s MutablePointIndexUpdate
removeMutablePoint MutablePointIndex{tableSlots, tableMask} readX readY x y retiredVertex =
  findRetired (fromIntegral (mixCoordinates x y) .&. tableMask) (tableMask + 1)
 where
  findRetired !slot !budget
    | budget <= 0 = pure MutablePointIndexInvalidated
    | otherwise = do
        occupant <- MUV.unsafeRead tableSlots slot
        if occupant == vacant
          then pure MutablePointIndexInvalidated
          else
            if fromIntegral occupant == retiredVertex
              then do
                MUV.unsafeWrite tableSlots slot vacant
                closeProbeHole slot ((slot + 1) .&. tableMask) (budget - 1)
              else findRetired ((slot + 1) .&. tableMask) (budget - 1)

  closeProbeHole !hole !slot !budget
    | budget <= 0 =
        MutablePointIndexInvalidated <$ MUV.unsafeWrite tableSlots hole vacant
    | otherwise = do
        occupant <- MUV.unsafeRead tableSlots slot
        if occupant == vacant
          then MutablePointIndexUpdated <$ MUV.unsafeWrite tableSlots hole vacant
          else do
            heldX <- readX (fromIntegral occupant)
            heldY <- readY (fromIntegral occupant)
            let !home = fromIntegral (mixCoordinates heldX heldY) .&. tableMask
                !distanceToHole = (hole - home) .&. tableMask
                !distanceToSlot = (slot - home) .&. tableMask
            if distanceToHole < distanceToSlot
              then do
                MUV.unsafeWrite tableSlots hole occupant
                closeProbeHole slot ((slot + 1) .&. tableMask) (budget - 1)
              else closeProbeHole hole ((slot + 1) .&. tableMask) (budget - 1)
{-# INLINE removeMutablePoint #-}

-- | Rename the tail handle after DCEL swap compaction. Failure remains a
-- typed cache obstruction, so its caller drops the table and later identity
-- questions descend from geometry rather than trusting an unproved cache.
relocateMutablePoint
  :: MutablePointIndex s
  -> Double
  -> Double
  -> Int
  -> Int
  -> ST s MutablePointIndexUpdate
relocateMutablePoint MutablePointIndex{tableSlots, tableMask} x y previousVertex currentVertex =
  findPrevious (fromIntegral (mixCoordinates x y) .&. tableMask) (tableMask + 1)
 where
  findPrevious !slot !budget
    | budget <= 0 = pure MutablePointIndexInvalidated
    | otherwise = do
        occupant <- MUV.unsafeRead tableSlots slot
        if occupant == vacant
          then pure MutablePointIndexInvalidated
          else
            if fromIntegral occupant == previousVertex
              then
                MutablePointIndexUpdated
                  <$ MUV.unsafeWrite tableSlots slot (fromIntegral currentVertex)
              else findPrevious ((slot + 1) .&. tableMask) (budget - 1)
{-# INLINE relocateMutablePoint #-}

-- | Answer the vertex already holding a canonical position, or claim the
-- position for @candidate@ and answer 'Nothing'. The position travels as raw
-- coordinates — the v'Point' constructor is the cold boundary's packaging and
-- has no business on the ingress path. The caller supplies the arena's
-- coordinate reader, so a claim is only sound if @candidate@ is the very next
-- vertex the arena will append.
resolveMutablePoint
  :: MutablePointIndex s
  -> (Int -> ST s Double)
  -> (Int -> ST s Double)
  -> Double
  -> Double
  -> Int
  -> ST s (Either BuildError (Maybe Int))
resolveMutablePoint MutablePointIndex{tableSlots, tableMask} readX readY x y candidate =
  probe (fromIntegral (mixCoordinates x y) .&. tableMask) (tableMask + 1)
 where
  probe !slot !budget
    | budget <= 0 = pure (Left (PointIndexCapacityExhausted (MUV.length tableSlots)))
    | otherwise = do
        occupant <- MUV.unsafeRead tableSlots slot
        if occupant == vacant
          then Right Nothing <$ MUV.unsafeWrite tableSlots slot (fromIntegral candidate)
          else do
            heldX <- readX (fromIntegral occupant)
            heldY <- readY (fromIntegral occupant)
            if heldX == x && heldY == y
              then pure (Right (Just (fromIntegral occupant)))
              else probe ((slot + 1) .&. tableMask) (budget - 1)

-- Inlined rather than merely specialized: the coordinate reader arrives as an
-- argument, so until the probe loop lands at its call site every collision pays
-- an unknown call and a boxed pair for a read the caller could have made
-- directly.
{-# INLINE resolveMutablePoint #-}

mixCoordinates :: Double -> Double -> Word64
mixCoordinates x y =
  mix
    ( castDoubleToWord64 (canonicalScalarZero x)
        `xor` mix (castDoubleToWord64 (canonicalScalarZero y))
    )
{-# INLINE mixCoordinates #-}

mix :: Word64 -> Word64
mix raw =
  let !z0 = raw + 0x9e3779b97f4a7c15
      !z1 = (z0 `xor` (z0 `shiftR` 30)) * 0xbf58476d1ce4e5b9
      !z2 = (z1 `xor` (z1 `shiftR` 27)) * 0x94d049bb133111eb
   in z2 `xor` (z2 `shiftR` 31)
{-# INLINE mix #-}

pointHash :: Double -> Double -> Int
pointHash x y = fromIntegral (mixCoordinates x y)
{-# INLINE pointHash #-}