packages feed

swarm-0.7.0.0: src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Tracking.hs

-- |
-- SPDX-License-Identifier: BSD-3-Clause
--
-- Online operations for structure recognizer.
--
-- See "Swarm.Game.Scenario.Topography.Structure.Recognition.Precompute" for
-- details of the structure recognition process.
module Swarm.Game.Scenario.Topography.Structure.Recognition.Tracking (
  RecognitionActiveStatus (..),
  entityModified,
  entityModifiedLoggable,
) where

import Control.Arrow (left, (&&&))
import Control.Lens ((%~), (&), (.~), (^.))
import Control.Monad (foldM, forM_, guard, unless)
import Control.Monad.Extra (findM)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT)
import Control.Monad.Trans.Writer.Strict
import Data.Either (partitionEithers)
import Data.Either.Extra (maybeToEither)
import Data.Function (on)
import Data.HashMap.Strict qualified as HM
import Data.HashSet qualified as HS
import Data.Hashable (Hashable)
import Data.IntSet qualified as IS
import Data.IntSet.NonEmpty (NEIntSet)
import Data.IntSet.NonEmpty qualified as NEIS
import Data.List (sortOn)
import Data.List.NonEmpty qualified as NE
import Data.Map qualified as M
import Data.Ord (Down (..))
import Data.Semigroup (Max (..), Min (..))
import Data.Tuple (swap)
import Linear (V2 (..))
import Swarm.Game.Location (Location)
import Swarm.Game.Scenario.Topography.Structure.Named (name)
import Swarm.Game.Scenario.Topography.Structure.Recognition
import Swarm.Game.Scenario.Topography.Structure.Recognition.Log
import Swarm.Game.Scenario.Topography.Structure.Recognition.Precompute (GenericEntLocator, ensureStructureIntact)
import Swarm.Game.Scenario.Topography.Structure.Recognition.Prep (binTuplesHM)
import Swarm.Game.Scenario.Topography.Structure.Recognition.Registry
import Swarm.Game.Scenario.Topography.Structure.Recognition.Type
import Swarm.Game.Scenario.Topography.Terraform
import Swarm.Game.Universe
import Text.AhoCorasick

data RecognitionActiveStatus
  = RecognizeNewStructures
  | -- | Do not add new recognitions to the registry.
    -- This is useful if one needs to construct a larger structure
    -- for which other smaller structures contained within it
    -- would otherwise be recognized first, precluding the larger
    -- structure from ever being recognized.
    -- Removing elements of a previously recognized structure
    -- will still cause it to be removed from the registry.
    DisableNewRecognition
  deriving (Show, Eq, Ord, Enum, Bounded)

-- | A hook called from the centralized entity update function,
-- 'Swarm.Game.Step.Util.updateEntityAt'.
entityModified ::
  (Monad s, Hashable a, Eq b) =>
  GenericEntLocator s a ->
  CellModification a ->
  Cosmic Location ->
  RecognizerAutomatons b a ->
  RecognitionState b a ->
  s (RecognitionState b a)
entityModified entLoader modification cLoc autoRecognizer oldRecognitionState = do
  (val, accumulatedLogs) <-
    runWriterT $
      entityModifiedLoggable RecognizeNewStructures entLoader modification cLoc autoRecognizer oldRecognitionState
  return $
    val
      & recognitionLog %~ (reverse accumulatedLogs <>)

-- | This handles structure detection upon addition of an entity,
-- and structure de-registration upon removal of an entity.
-- Also handles atomic entity swaps.
entityModifiedLoggable ::
  (Monoid (f (SearchLog a)), Monad m, Hashable a, Eq b, Applicative f) =>
  RecognitionActiveStatus ->
  (Cosmic Location -> m (AtomicKeySymbol a)) ->
  CellModification a ->
  Cosmic Location ->
  RecognizerAutomatons b a ->
  RecognitionState b a ->
  WriterT (f (SearchLog a)) m (RecognitionState b a)
entityModifiedLoggable activeStatus entLoader modification cLoc autoRecognizer oldRecognitionState = do
  case modification of
    Add newEntity -> doAddition newEntity oldRecognitionState
    Remove _ -> doRemoval oldRecognitionState
    Swap _ newEntity -> doRemoval oldRecognitionState >>= doAddition newEntity
 where
  entLookup = autoRecognizer ^. automatonsByEntity

  doAddition newEntity = case activeStatus of
    RecognizeNewStructures -> maybe return logAndRegister $ HM.lookup newEntity entLookup
    DisableNewRecognition -> return
   where
    logAndRegister finder s = do
      tell . pure . FoundParticipatingEntity $
        ParticipatingEntity
          newEntity
          (finder ^. inspectionOffsets)
      newFoundStructures <- registerRowMatches entLoader cLoc finder $ s ^. foundStructures
      return $ s & foundStructures .~ newFoundStructures

  doRemoval sOld =
    -- Entity was removed; may need to remove registered structure.
    maybe return logAndRemove structureAtLoc sOld
   where
    structureAtLoc = M.lookup cLoc $ foundByLocation $ sOld ^. foundStructures
    logAndRemove fs s = do
      tell $ pure $ StructureRemoved structureName
      return $ s & foundStructures %~ removeStructure fs
     where
      structureName = name . originalItem . entityGrid $ structureWithGrid fs

-- | In case this cell would match a candidate structure,
-- ensures that the entity in this cell is not already
-- participating in a registered structure.
--
-- Furthermore, treating cells in registered structures
-- as 'Nothing' has the effect of "masking" them out,
-- so that they can overlap empty cells within the bounding
-- box of the candidate structure.
candidateEntityAt ::
  (Monad s, Hashable a) =>
  GenericEntLocator s a ->
  FoundRegistry b a ->
  Cosmic Location ->
  s (AtomicKeySymbol a)
candidateEntityAt entLoader registry cLoc = runMaybeT $ do
  guard $ M.notMember cLoc $ foundByLocation registry
  MaybeT $ entLoader cLoc

-- | Excludes entities that are already part of a
-- registered found structure.
getWorldRow ::
  (Monad s, Hashable a) =>
  GenericEntLocator s a ->
  FoundRegistry b a ->
  Cosmic Location ->
  InspectionOffsets ->
  s [AtomicKeySymbol a]
getWorldRow entLoader registry cLoc (InspectionOffsets (Min offsetLeft) (Max offsetRight)) = do
  mapM getCandidate horizontalOffsets
 where
  getCandidate = candidateEntityAt entLoader registry
  horizontalOffsets = map mkLoc [offsetLeft .. offsetRight]
  mkLoc x = cLoc `offsetBy` V2 x 0

-- | This runs once per non-overlapping subset of found chunks
checkChunksCombination ::
  (Monoid (f (SearchLog a)), Applicative f, Monad m, Hashable a, Eq b) =>
  Cosmic Location ->
  InspectionOffsets ->
  NE.NonEmpty (RowChunkMatchingReference b a) ->
  [Position (NE.NonEmpty a)] ->
  WriterT (f (SearchLog a)) m [FoundStructure b a]
checkChunksCombination
  cLoc
  horizontalOffsets
  rowChunkReferences
  candidatesChunked = do
    tell . pure . FoundPiecewiseChunks . map swap . HM.toList $
      fmap NEIS.elems foundRowChunksLookup

    tell . pure . ChunkFailures $ candidateFailures

    tell . pure . ChunksMatchingExpected $
      map (modifyChunkedRowMatch $ fmap renderSharedNames) candidateExpected

    return structurePositionsToCheck
   where
    structurePositionsToCheck = concatMap mkFoundStructures candidateExpected

    candidateExpected = concatMap NE.toList candidateExpectedLists

    foundRowChunksLookup =
      fmap NEIS.fromList $
        binTuplesHM $
          map (pVal &&& pIndex) candidatesChunked

    (candidateFailures, candidateExpectedLists) =
      partitionEithers $
        map (checkCandidateAgainstObservedChunks horizontalOffsets foundRowChunksLookup) $
          NE.toList rowChunkReferences

    mkFoundStructures x =
      NE.toList $ NE.map mkFoundStructure . referencingRows . chunkStructure $ foundChunkRow x
     where
      mkFoundStructure r =
        PositionedStructure
          (cLoc `offsetBy` theOffset)
          (wholeStructure r)
       where
        theOffset = V2 (horizontalStructPos $ foundChunkRow x) (rowIndex r)

    modifyChunkedRowMatch f (ChunkedRowMatch x y) = ChunkedRowMatch x (f y)

checkCandidateAgainstObservedChunks ::
  Hashable e =>
  InspectionOffsets ->
  HM.HashMap (NE.NonEmpty e) NEIntSet ->
  RowChunkMatchingReference b e ->
  Either (ChunkMatchFailureReason e) (NE.NonEmpty (ChunkedRowMatch (ConsolidatedRowReferences b e) e))
checkCandidateAgainstObservedChunks horizontalOffsets foundRowChunksLookup (RowChunkMatchingReference r chunkPositionMap) =
  left (ChunkMatchFailureReason $ renderSharedNames r) $ do
    unless isKeysSubset . Left $
      NoKeysSubset $
        (FoundChunkComparison `on` HS.toList) foundChunksKeys referenceChunksKeys

    nonEmptyPairs <-
      maybeToEither EmptyIntersection $
        NE.nonEmpty sortedByAlignmentChoices

    let maybeViables = do
          possibles <- seedPossibleOffsets $ snd $ NE.head nonEmptyPairs
          foldM findCoveringOffsets possibles $ NE.map (snd . snd) nonEmptyPairs

    viableRowOffsets <- maybeToEither EmptyIntersection maybeViables
    return $ NE.map mkRowMatch $ NEIS.toList viableRowOffsets
 where
  theIntersection =
    HM.intersectionWith
      FoundAndExpectedChunkPositions
      foundRowChunksLookup
      modifiedChunkPositionMap
  intersectionWithSizeDifferences = HM.map (sizeDifference &&& id) theIntersection
   where
    sizeDifference x = (subtract `on` NEIS.size) (expectedPositions x) (foundPositions x)

  -- Remove the pairings that have fewer occurrences than the required number.
  -- The 'fst' element of the tuple is the difference between the "observed" and "required" count.
  withSufficientCoverage = HM.filter ((>= 0) . fst) intersectionWithSizeDifferences
  sortedByAlignmentChoices = sortOn (fst . snd) $ HM.toList withSufficientCoverage

  isKeysSubset = referenceChunksKeys `HS.isSubsetOf` foundChunksKeys

  mkRowMatch rowOffset =
    ChunkedRowMatch
      (map swap $ HM.toList theIntersection)
      (FoundRowFromChunk rowOffset horizontalStructurePosition r)
   where
    horizontalStructurePosition = fromIntegral rowOffset + getMin (startOffset horizontalOffsets)

  modifiedChunkPositionMap = fmap NEIS.fromList chunkPositionMap
  foundChunksKeys = HM.keysSet foundRowChunksLookup
  referenceChunksKeys = HM.keysSet chunkPositionMap

-- | Search for any structure row that happens to
-- contain the placed entity.
registerRowMatches ::
  (Monoid (f (SearchLog a)), Applicative f, Monad s, Hashable a, Eq b) =>
  GenericEntLocator s a ->
  Cosmic Location ->
  AutomatonInfo b a ->
  FoundRegistry b a ->
  WriterT (f (SearchLog a)) s (FoundRegistry b a)
registerRowMatches entLoader cLoc (AutomatonInfo horizontalOffsets pwMatcher) registry = do
  tell $ pure $ StartSearchAt cLoc horizontalOffsets

  tell . pure . ExpectedChunks $
    NE.map (HM.keys . confirmationMap) rowChunkReferences

  entitiesRow <-
    lift $
      getWorldRow
        entLoader
        registry
        cLoc
        horizontalOffsets

  tell . pure . WorldRowContent $ entitiesRow

  let candidatesChunked = findAll pwSM entitiesRow
  unrankedCandidateStructures <- checkCombo candidatesChunked

  -- [STRUCTURE RECOGNIZER CONFLICT RESOLUTION]
  -- We only allow an entity to participate in one structure at a time,
  -- so multiple matches require a tie-breaker.
  -- The largest structure (by area) shall win.
  -- Sort by decreasing order of preference
  -- (see the Ord instance of 'FoundStructure').
  let rankedCandidates = sortOn Down unrankedCandidateStructures
  tell . pure . FoundCompleteStructureCandidates $
    map getStructInfo rankedCandidates

  -- We should not check all of the structures, which can be expensive.
  -- Instead, we ranked the candidates by preference a-priori
  -- and now choose the first one that is verified.
  maybeIntactStructure <- findM validateIntactness2d rankedCandidates

  forM_ maybeIntactStructure $
    tell . pure . RecognizedSingleStructure . getStructInfo

  return $ maybe id addFound maybeIntactStructure registry
 where
  PiecewiseRecognition pwSM rowChunkReferences = pwMatcher

  getStructInfo (PositionedStructure loc swg) = (distillLabel swg, loc)

  validateIntactness2d fs = do
    maybeIntactnessFailure <- lift $ ensureStructureIntact registry entLoader fs
    tell . pure . ChunkIntactnessVerification
      $ IntactPlacementLog
        maybeIntactnessFailure
      $ PositionedStructure (upperLeftCorner fs) (distillLabel . structureWithGrid $ fs)

    return $ null maybeIntactnessFailure

  checkCombo = checkChunksCombination cLoc horizontalOffsets rowChunkReferences

-- |
-- For a given "chunk", there could be multiple recurrences.
-- However, the position of each recurrence is unique
-- (i.e. the chunk cannot exist twice at the same location).
--
-- Either:
-- A) An observed chunk is "superfluous" w.r.t. matching the candidate, or
-- B) It is necessary for the match.
--
-- The lowest-numbered "reference position" (i.e. in the structure definition)
-- of a given chunk must align with exactly one "observed position".
--
-- The difference between the "observed" position of the chunk that aligns with the
-- lowest-numbered "reference position" shall be the global "row offset" applied to our observations.
-- This row offset value applies to all "chunks" (both identical and distinct) that comprise the row.
--
-- If a given chunk occurrence is necessary for the match, then we may attempt to use it to compute
-- the "row offset" by taking its position minus the lowest-numbered "reference position".
--
-- We can iterate over each occurrence position in ascending order.
-- In the ideal case, the first such candidate ends up being the the actual, valid, offset.
-- Otherwise, we know that all invalid offset candidates encountered before the first valid
-- offset constitute "superfluous" chunks.
--
-- Note that there may exist multiple valid "row offsets".
-- At most, there will be
--   {number of observed occurrences} minus {number of required occurrences}
-- such offsets.
--
-- = Performance notes
--
-- We only have to do this computation once, and only for the "smallest" size discrepancy
-- between occurrences and references of a chunk. This generates the "seed" pool of possible offsets.
-- All subsequent chunks will merely filter on this initial set.
seedPossibleOffsets :: (Int, FoundAndExpectedChunkPositions) -> Maybe NEIntSet
seedPossibleOffsets (sizeDifference, FoundAndExpectedChunkPositions found expected) =
  NEIS.nonEmptySet $ IS.fromList possibleOffsets
 where
  possibleOffsets =
    NE.take (sizeDifference + 1) $
      NE.map (subtract (NEIS.findMin expected)) $
        NEIS.toAscList found

-- | Return all of the offsets that are viable for repetitions of this chunk.
--
-- Note that if there are an equal number of observed occurrences
-- and expected occurrences, then there is only one possible offset.
-- If there are N expected and (N + 1) observed, then there are 2 possible offsets.
findCoveringOffsets :: NEIntSet -> FoundAndExpectedChunkPositions -> Maybe NEIntSet
findCoveringOffsets possibleOffsets x =
  NEIS.nonEmptySet $ NEIS.filter (isCoveredWithOffset x) possibleOffsets

isCoveredWithOffset :: FoundAndExpectedChunkPositions -> Int -> Bool
isCoveredWithOffset (FoundAndExpectedChunkPositions found expected) offset =
  NEIS.map (+ offset) expected `NEIS.isSubsetOf` found