diff --git a/data/scenarios/Testing/1575-structure-recognizer/00-ORDER.txt b/data/scenarios/Testing/1575-structure-recognizer/00-ORDER.txt index 89e85e243..aad980a98 100644 --- a/data/scenarios/Testing/1575-structure-recognizer/00-ORDER.txt +++ b/data/scenarios/Testing/1575-structure-recognizer/00-ORDER.txt @@ -17,4 +17,3 @@ 2115-encroaching-upon-exterior-transparent-cells.yaml 2115-encroaching-upon-interior-transparent-cells.yaml 2201-piecewise-lines.yaml -2201-piecewise-solid.yaml diff --git a/data/scenarios/Testing/1575-structure-recognizer/2201-piecewise-solid.yaml b/data/scenarios/Testing/1575-structure-recognizer/2201-piecewise-solid.yaml deleted file mode 100644 index 260f41476..000000000 --- a/data/scenarios/Testing/1575-structure-recognizer/2201-piecewise-solid.yaml +++ /dev/null @@ -1,73 +0,0 @@ -version: 1 -name: Structure recognition - piecewise lines -description: | - General solution for transparency -creative: false -objectives: - - teaser: Recognize structure - goal: - - | - `spaceship`{=structure} structure should be recognized upon completion, - even with an extraneous entity within its bounds. - condition: | - def isRight = \x. case x (\_. false) (\_. true); end; - foundStructure <- structure "spaceship" 0; - return $ isRight foundStructure; -robots: - - name: base - dir: east - devices: - - ADT calculator - - blueprint - - fast grabber - - logger - - treads - inventory: - - [1, rock] -solution: | - move; move; move; - swap "rock"; -structures: - - name: spaceship - recognize: [north] - structure: - palette: - 'p': [stone, board] - 'x': [stone, rock] - mask: '.' - map: | - xxx - ppp - xxx - - name: damage - description: A single-cell overwrite of the spaceship - structure: - palette: - 't': [stone, tree] - map: | - t - - name: modified ship - description: A spaceship with a single cell replaced by a `tree`{=entity} - structure: - placements: - - src: spaceship - - src: damage - map: "" -known: [board, mountain, rock, tree] -world: - dsl: | - {blank} - palette: - '.': [grass, erase] - 'B': [grass, erase, base] - 'p': - structure: - name: modified ship - cell: [grass] - upperleft: [0, 0] - map: | - ......... - B..p..... - ......... - ......... - ......... diff --git a/src/swarm-engine/Swarm/Game/Step/Util.hs b/src/swarm-engine/Swarm/Game/Step/Util.hs index 607db2db5..e0acc276d 100644 --- a/src/swarm-engine/Swarm/Game/Step/Util.hs +++ b/src/swarm-engine/Swarm/Game/Step/Util.hs @@ -67,8 +67,7 @@ adaptGameState :: TS.State GameState b -> m b adaptGameState f = do - oldGS <- get - let (newRecognizer, newGS) = TS.runState f oldGS + (newRecognizer, newGS) <- TS.runState f <$> get put newGS return newRecognizer diff --git a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Log.hs b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Log.hs index 977c687bc..8efbca83f 100644 --- a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Log.hs +++ b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Log.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE OverloadedStrings #-} + -- | -- SPDX-License-Identifier: BSD-3-Clause -- @@ -5,11 +7,11 @@ module Swarm.Game.Scenario.Topography.Structure.Recognition.Log where import Data.Aeson -import Data.Int (Int32) -import Data.IntSet.NonEmpty (NEIntSet) import Data.List.NonEmpty (NonEmpty) +import Data.List.NonEmpty qualified as NE +import Data.Text (Text) +import Data.Text qualified as T import GHC.Generics (Generic) -import Linear (V2) import Servant.Docs (ToSample) import Servant.Docs qualified as SD import Swarm.Game.Location (Location) @@ -17,11 +19,6 @@ import Swarm.Game.Scenario.Topography.Structure.Recognition.Type import Swarm.Game.Universe (Cosmic) import Swarm.Language.Syntax.Direction (AbsoluteDir) --- | Type aliases for documentation -type StructureRowContent e = SymbolSequence e - -type WorldRowContent e = SymbolSequence e - data OrientedStructure = OrientedStructure { oName :: OriginalName , oDir :: AbsoluteDir @@ -31,6 +28,10 @@ data OrientedStructure = OrientedStructure distillLabel :: StructureWithGrid b a -> OrientedStructure distillLabel swg = OrientedStructure (getName $ originalDefinition swg) (rotatedTo swg) +renderSharedNames :: ConsolidatedRowReferences b a -> Text +renderSharedNames = + T.intercalate "/" . NE.toList . NE.nub . NE.map (getName . originalDefinition . wholeStructure) . referencingRows + newtype EntityKeyedFinder = EntityKeyedFinder { searchOffsets :: InspectionOffsets } @@ -65,56 +66,19 @@ data RowMismatchReason e EmptyIntersection deriving (Functor, Generic, ToJSON) -data WrongRecurrenceCountExplanation = WrongRecurrenceCountExplanation - { foundCount :: Int - , foundMembers :: NonEmpty Int - , expectedCount :: Int - , expectedMembers :: NonEmpty Int - } - deriving (Generic, ToJSON) - --- | The located occurrences of a specific contiguous chunk of entities. --- Note that an identical chunk may recur more than once in a structure row. --- This record represents all of the recurrences of one such chunk. --- --- Any different chunks contained within a row will be described by --- their own instance of this record. --- --- Note: By virtue of the searching algorithm, these indices --- are expected to automatically be in sorted order -data FoundAndExpectedChunkPositions = FoundAndExpectedChunkPositions - { foundPositions :: NEIntSet - , expectedPositions :: NEIntSet - } - deriving (Generic, ToJSON) - -data FoundRowFromChunk a = FoundRowFromChunk - { chunkOffsetFromSearchBorder :: Int - , rowIndexWithinStructure :: Int32 - , structurePositionOffset :: V2 Int32 - , chunkStructure :: a - } - deriving (Functor, Generic, ToJSON) - -data ChunkedRowMatch a e = ChunkedRowMatch - { positionsComparison :: [(FoundAndExpectedChunkPositions, NonEmpty e)] - , foundChunkRow :: FoundRowFromChunk a - } - deriving (Functor, Generic, ToJSON) - data SearchLog e - = FoundParticipatingEntity (ParticipatingEntity e) - | StructureRemoved OriginalName - | FoundCompleteStructureCandidates [OrientedStructure] + = IntactStaticPlacement [IntactPlacementLog] + | StartSearchAt (Cosmic Location) InspectionOffsets + | FoundParticipatingEntity (ParticipatingEntity e) + | FoundCompleteStructureCandidates [(OrientedStructure, Cosmic Location)] | -- | this is actually internally used as a (Map (NonEmpty e) (NonEmpty Int)), -- but the requirements of Functor force us to invert the mapping FoundPiecewiseChunks [(NonEmpty Int, NonEmpty e)] | ExpectedChunks (NonEmpty [NonEmpty e]) - | StartSearchAt (Cosmic Location) InspectionOffsets | ChunksMatchingExpected [ChunkedRowMatch OriginalName e] | ChunkFailures [ChunkMatchFailureReason e] | ChunkIntactnessVerification IntactPlacementLog - | IntactStaticPlacement [IntactPlacementLog] + | StructureRemoved OriginalName deriving (Functor, Generic) instance (ToJSON e) => ToJSON (SearchLog e) where diff --git a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Precompute.hs b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Precompute.hs index 0441baa99..d582217e5 100644 --- a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Precompute.hs +++ b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Precompute.hs @@ -17,13 +17,11 @@ -- -- The first searching stage looks for any member row of all participating -- structure definitions that contains the placed entity. --- The value returned by the searcher is a second-stage searcher state machine, --- which this time searches for complete structures of which the found row may --- be a member. --- --- Both the first stage and second stage searcher know to start the search --- at a certain offset horizontally or vertically from the placed entity, --- based on where within a structure that entity (or row) may occur. +-- If we observe a row in the world that happens to occur in a structure, we use both +-- the horizontal found offset and the index of the row within this structure to compute +-- the expected world location of the candidate structure. +-- Then we perform a full scan of that candidate structure against the world to verify +-- the match. -- -- Upon locating a complete structure, it is added to a registry -- (see 'Swarm.Game.Scenario.Topography.Structure.Recognition.Registry.FoundRegistry'), which @@ -50,10 +48,10 @@ import Data.Hashable (Hashable) import Data.Map qualified as M import Data.Maybe (catMaybes, mapMaybe) import Data.Set qualified as Set -import Linear (V2 (..)) -import Swarm.Game.Location (Location) +import Data.Tuple (swap) +import Swarm.Game.Location (Location, asVector) import Swarm.Game.Scenario.Topography.Area (getGridDimensions, rectWidth) -import Swarm.Game.Scenario.Topography.Grid (getRows) +import Swarm.Game.Scenario.Topography.Grid (getRows, mapIndexedMembers, mkGrid) import Swarm.Game.Scenario.Topography.Placement (Orientation (..), applyOrientationTransform, getStructureName) import Swarm.Game.Scenario.Topography.Structure.Named import Swarm.Game.Scenario.Topography.Structure.Recognition.Prep ( @@ -65,6 +63,7 @@ import Swarm.Game.Scenario.Topography.Structure.Recognition.Registry ( import Swarm.Game.Scenario.Topography.Structure.Recognition.Static import Swarm.Game.Scenario.Topography.Structure.Recognition.Type import Swarm.Game.Universe (Cosmic (..), offsetBy) +import Swarm.Game.World.Coords (coordsToLoc) import Swarm.Language.Syntax.Direction (AbsoluteDir) import Swarm.Util (histogram) @@ -145,22 +144,22 @@ lookupStaticPlacements extractor (StaticStructureInfo structDefs thePlacements) -- | Matches definitions against the placements. -- Fails fast (short-circuits) if a non-matching -- cell is encountered. +-- +-- Returns 'Nothing' if there is no discrepancy between the match subject and world content. +-- Returns the first observed mismatch cell otherwise. ensureStructureIntact :: (Monad s, Hashable a) => GenericEntLocator s a -> FoundStructure b a -> s Bool -ensureStructureIntact entLoader (FoundStructure (StructureWithGrid _ _ _ grid) upperLeft) = - allM outer $ zip [0 ..] grid +ensureStructureIntact entLoader (FoundStructure (StructureWithGrid _ _ _ grid) upperLeft) = do + allM checkLoc allLocPairs where - outer (y, row) = allM (inner y) $ zip [0 ..] row - inner y (x, maybeTemplateEntity) = case maybeTemplateEntity of + checkLoc (maybeTemplateEntity, loc) = case maybeTemplateEntity of Nothing -> return True - Just _ -> - fmap (== maybeTemplateEntity) $ - entLoader $ - mkLoc x y + Just x -> (== Just x) <$> entLoader loc -- NOTE: We negate the yOffset because structure rows are numbered increasing from top -- to bottom, but swarm world coordinates increase from bottom to top. - mkLoc x y = upperLeft `offsetBy` V2 x (negate y) + f = fmap ((upperLeft `offsetBy`) . asVector . coordsToLoc) . swap + allLocPairs = mapIndexedMembers (curry f) $ mkGrid grid diff --git a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Prep.hs b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Prep.hs index 75c9bd5b9..75e4fdd1f 100644 --- a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Prep.hs +++ b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Prep.hs @@ -54,30 +54,32 @@ mkOffsets pos (RowWidth w) = mkEntityLookup :: (Hashable a, Eq a) => [StructureWithGrid b a] -> - HM.HashMap a (AutomatonNewInfo b a) + HM.HashMap a (AutomatonInfo b a) mkEntityLookup grids = HM.map mkRowAutomatons rowsByEntityParticipation where -- Produces an automaton to evaluate whenever a given entity -- is encountered. mkRowAutomatons neList = - AutomatonNewInfo bounds $ - PiecewiseRecognition smPiecewise extractedChunksForLookup + AutomatonInfo bounds $ + PiecewiseRecognition chunksStateMachine extractedChunksForLookup where bounds = sconcat $ NE.map expandedOffsets neList - extractedChunksForStateMachine = - HS.fromList . concat . NE.toList $ - NE.map (map chunkContents . contiguousChunks) neList - - -- TODO: These should be grouped by - -- row content to avoid redundant checks + -- Prepare lookup structure for use with results of the + -- Aho-Corasick matcher. extractedChunksForLookup = NE.map f neList where f x = RowChunkMatchingReference (myRow x) (mkRightMap x) mkRightMap = binTuplesHM . map (chunkContents &&& chunkStartPos) . contiguousChunks - smPiecewise = + extractedChunksForStateMachine = + HS.fromList . concat . NE.toList $ + NE.map (map chunkContents . contiguousChunks) neList + + -- We wrap the entities with 'Just' since the Aho-Corasick + -- matcher needs to compare against world cells, which are of 'Maybe' type. + chunksStateMachine = makeStateMachine $ map (NE.toList . fmap Just &&& id) $ HS.toList extractedChunksForStateMachine @@ -85,15 +87,24 @@ mkEntityLookup grids = -- The values of this map are guaranteed to contain only one -- entry per row of each structure, even if some of those -- rows contain repetition of the same entity. - -- That is not to say that there are not recurrences of identical rows, - -- though, if the same structure or a different structure has some identical rows. rowsByEntityParticipation = binTuplesHM . map (myEntity &&& id) . concatMap explodeRowEntities + $ structureRowsByContent + + -- Consolidate all identical rows, whether those rows appear in + -- same structure or a different structures. + structureRowsByContent = + map (\(x, y) -> ConsolidatedRowReferences x y . gridWidth . wholeStructure $ NE.head y) + . HM.toList + . binTuplesHM + . map (rowContent &&& id) $ allStructureRows grids -getContiguousChunks :: [Maybe a] -> [PositionedChunk a] +-- | Utilizes the convenient 'wordsBy' function +-- from the "split" package. +getContiguousChunks :: SymbolSequence a -> [PositionedChunk a] getContiguousChunks rowMembers = map mkChunk . mapMaybe (NE.nonEmpty . mapMaybe sequenceA) @@ -109,9 +120,9 @@ getContiguousChunks rowMembers = -- are dropped but accounted for positionally when indexing the columns. explodeRowEntities :: (Hashable a, Eq a) => - StructureRow b a -> + ConsolidatedRowReferences b a -> [SingleRowEntityOccurrences b a] -explodeRowEntities annotatedRow@(StructureRow _ _ rowMembers) = +explodeRowEntities annotatedRow@(ConsolidatedRowReferences rowMembers _ width) = map f $ HM.toList $ binTuplesHM unconsolidatedEntityOccurrences where chunks = getContiguousChunks rowMembers @@ -130,8 +141,7 @@ explodeRowEntities annotatedRow@(StructureRow _ _ rowMembers) = zipWith (\idx -> fmap (PositionWithinRow idx annotatedRow,)) [0 ..] rowMembers deriveEntityOffsets :: PositionWithinRow b a -> InspectionOffsets - deriveEntityOffsets (PositionWithinRow pos r) = - mkOffsets pos $ gridWidth $ wholeStructure r + deriveEntityOffsets (PositionWithinRow pos _) = mkOffsets pos width -- * Util diff --git a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Tracking.hs b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Tracking.hs index 333b4b859..ef8548a35 100644 --- a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Tracking.hs +++ b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Tracking.hs @@ -11,7 +11,8 @@ module Swarm.Game.Scenario.Topography.Structure.Recognition.Tracking ( import Control.Arrow (left, (&&&)) import Control.Lens ((%~), (&), (.~), (^.)) -import Control.Monad (filterM, foldM, guard, unless) +import Control.Monad (foldM, 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 @@ -21,14 +22,12 @@ import Data.Function (on) import Data.HashMap.Strict qualified as HM import Data.HashSet qualified as HS import Data.Hashable (Hashable) -import Data.Int (Int32) 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.Maybe (listToMaybe) import Data.Ord (Down (..)) import Data.Semigroup (Max (..), Min (..)) import Data.Tuple (swap) @@ -69,26 +68,20 @@ entityModified entLoader modification cLoc recognizer = do entLookup = recognizer ^. automatons . automatonsByEntity doAddition newEntity r = do - let oldRecognitionState = r ^. recognitionState stateRevision <- case HM.lookup newEntity entLookup of Nothing -> return oldRecognitionState Just finder -> do - let logFinder f = - EntityKeyedFinder - (f ^. inspectionOffsets2) - msg = - FoundParticipatingEntity $ - ParticipatingEntity newEntity $ - logFinder finder - tell $ pure msg + let logFinder f = EntityKeyedFinder (f ^. inspectionOffsets) + tell . pure . FoundParticipatingEntity . ParticipatingEntity newEntity $ + logFinder finder registerRowMatches entLoader cLoc finder oldRecognitionState return $ r & recognitionState .~ stateRevision + where + oldRecognitionState = r ^. recognitionState doRemoval = do -- Entity was removed; may need to remove registered structure. - let oldRecognitionState = recognizer ^. recognitionState - structureRegistry = oldRecognitionState ^. foundStructures stateRevision <- case M.lookup cLoc $ foundByLocation structureRegistry of Nothing -> return oldRecognitionState Just fs -> do @@ -100,6 +93,9 @@ entityModified entLoader modification cLoc recognizer = do structureName = getName $ originalDefinition $ structureWithGrid fs return $ recognizer & recognitionState .~ stateRevision + where + oldRecognitionState = recognizer ^. recognitionState + structureRegistry = oldRecognitionState ^. foundStructures -- | In case this cell would match a candidate structure, -- ensures that the entity in this cell is not already @@ -109,11 +105,6 @@ entityModified entLoader modification cLoc recognizer = do -- as 'Nothing' has the effect of "masking" them out, -- so that they can overlap empty cells within the bounding -- box of the candidate structure. --- --- Finally, entities that are not members of any candidate --- structure are also masked out, so that it is OK for them --- to intrude into the candidate structure's bounding box --- where the candidate structure has empty cells. candidateEntityAt :: (Monad s, Hashable a) => GenericEntLocator s a -> @@ -132,119 +123,123 @@ getWorldRow :: FoundRegistry b a -> Cosmic Location -> InspectionOffsets -> - Int32 -> s [Maybe a] -getWorldRow entLoader registry cLoc (InspectionOffsets (Min offsetLeft) (Max offsetRight)) yOffset = do +getWorldRow entLoader registry cLoc (InspectionOffsets (Min offsetLeft) (Max offsetRight)) = do mapM getCandidate horizontalOffsets where getCandidate = candidateEntityAt entLoader registry horizontalOffsets = map mkLoc [offsetLeft .. offsetRight] - - -- NOTE: We negate the yOffset because structure rows are numbered increasing from top - -- to bottom, but swarm world coordinates increase from bottom to top. - mkLoc x = cLoc `offsetBy` V2 x (negate yOffset) + mkLoc x = cLoc `offsetBy` V2 x 0 -- | This runs once per non-overlapping subset of found chunks checkChunksCombination :: (Monad m, Hashable a, Eq b) => - GenericEntLocator m a -> Cosmic Location -> InspectionOffsets -> NE.NonEmpty (RowChunkMatchingReference b a) -> [Position (NE.NonEmpty a)] -> WriterT [SearchLog a] m [FoundStructure b a] checkChunksCombination - entLoader cLoc horizontalOffsets rowChunkReferences candidatesChunked = do tell . pure . FoundPiecewiseChunks . map swap $ HM.toList $ fmap NEIS.elems foundRowChunksLookup - let (candidateFailures, candidateExpectedLists) = - partitionEithers $ - map subsetChecker $ - NE.toList rowChunkReferences - tell . pure . ChunkFailures $ candidateFailures - let candidateExpected = concatMap NE.toList candidateExpectedLists tell . pure . ChunksMatchingExpected $ - map (modifyChunkedRowMatch $ fmap $ getName . originalDefinition . wholeStructure) candidateExpected + map (modifyChunkedRowMatch $ fmap renderSharedNames) candidateExpected - let structurePositionsToCheck = map mkFoundStructure candidateExpected - filterM validateIntactness2d structurePositionsToCheck + return structurePositionsToCheck where + structurePositionsToCheck = concatMap mkFoundStructures candidateExpected + + candidateExpected = concatMap NE.toList candidateExpectedLists + foundRowChunksLookup = fmap NEIS.fromList $ binTuplesHM $ map (pVal &&& pIndex) candidatesChunked - mkFoundStructure x = - FoundStructure - (wholeStructure $ chunkStructure $ foundChunkRow x) - (cLoc `offsetBy` structurePositionOffset (foundChunkRow x)) - - validateIntactness2d fs = do - intactness <- lift $ ensureStructureIntact entLoader fs - tell . pure . ChunkIntactnessVerification $ - IntactPlacementLog - intactness - (getName . originalDefinition . structureWithGrid $ fs) - (upperLeftCorner fs) - return intactness - - subsetChecker (RowChunkMatchingReference r chunkPositionMap) = - left (ChunkMatchFailureReason $ getName . originalDefinition . wholeStructure $ r) $ do - let isKeysSubset = referenceChunksKeys `HS.isSubsetOf` foundChunksKeys - unless isKeysSubset . Left $ - NoKeysSubset $ - (FoundChunkComparison `on` HS.toList) foundChunksKeys referenceChunksKeys - - let theIntersection = - HM.intersectionWith - FoundAndExpectedChunkPositions - foundRowChunksLookup - modifiedChunkPositionMap - intersectionWithSizeDifferences = HM.map (criteria &&& id) theIntersection - where - criteria x = (subtract `on` NEIS.size) (expectedPositions x) (foundPositions x) - - -- Remove the pairings that have fewer occurrences than the required number - let withSufficientCoverage = HM.filter ((>= 0) . fst) intersectionWithSizeDifferences - sortedByAlignmentChoices = sortOn (fst . snd) $ HM.toList withSufficientCoverage - - nonEmptyPairs <- - maybeToEither EmptyIntersection $ - NE.nonEmpty sortedByAlignmentChoices - - let maybeViables = do - possibles <- generatePossibleOffsets $ snd $ NE.head nonEmptyPairs - foldM findCoveringOffsets possibles $ NE.map (snd . snd) nonEmptyPairs - - viableRowOffsets <- maybeToEither EmptyIntersection maybeViables - return $ NE.map (mkRowMatch theIntersection) $ NEIS.toList viableRowOffsets + (candidateFailures, candidateExpectedLists) = + partitionEithers $ + map (checkCandidateAgainstObservedChunks horizontalOffsets foundRowChunksLookup) $ + NE.toList rowChunkReferences + + mkFoundStructures x = + NE.toList $ NE.map mkFoundStructure . referencingRows . chunkStructure $ foundChunkRow x where - mkRowMatch theIntersection rowOffset = - ChunkedRowMatch - (map swap $ HM.toList theIntersection) - (FoundRowFromChunk rowOffset (rowIndex r) (V2 horizontalStructurePosition (rowIndex r)) r) + mkFoundStructure r = + FoundStructure + (wholeStructure r) + (cLoc `offsetBy` theOffset) where - horizontalStructurePosition = fromIntegral rowOffset + getMin (startOffset horizontalOffsets) - - modifiedChunkPositionMap = fmap NEIS.fromList chunkPositionMap - foundChunksKeys = HM.keysSet foundRowChunksLookup - referenceChunksKeys = HM.keysSet chunkPositionMap + 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 (criteria &&& id) theIntersection + where + criteria 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 :: (Monad s, Hashable a, Eq b) => GenericEntLocator s a -> Cosmic Location -> - AutomatonNewInfo b a -> + AutomatonInfo b a -> RecognitionState b a -> WriterT [SearchLog a] s (RecognitionState b a) -registerRowMatches entLoader cLoc (AutomatonNewInfo horizontalOffsets pwMatcher) rState = do +registerRowMatches entLoader cLoc (AutomatonInfo horizontalOffsets pwMatcher) rState = do + tell $ pure $ StartSearchAt cLoc horizontalOffsets + + tell . pure . ExpectedChunks $ NE.map (HM.keys . confirmationMap) rowChunkReferences + entitiesRow <- lift $ getWorldRow @@ -252,32 +247,76 @@ registerRowMatches entLoader cLoc (AutomatonNewInfo horizontalOffsets pwMatcher) registry cLoc horizontalOffsets - 0 - - tell $ pure $ StartSearchAt cLoc horizontalOffsets - - tell . pure . ExpectedChunks $ NE.map (HM.keys . confirmationMap) pwMaps let candidatesChunked = findAll pwSM entitiesRow + unrankedCandidateStructures <- checkCombo candidatesChunked + + -- 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. + let rankedCandidates = sortOn Down unrankedCandidateStructures + tell . pure . FoundCompleteStructureCandidates $ map getStructInfo rankedCandidates - intactStructuresLists <- checkCombo candidatesChunked + -- 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 - registerStructureMatches intactStructuresLists rState + lift $ registerBestStructureMatch maybeIntactStructure rState where registry = rState ^. foundStructures - PiecewiseRecognition pwSM pwMaps = pwMatcher + PiecewiseRecognition pwSM rowChunkReferences = pwMatcher - checkCombo = - checkChunksCombination - entLoader - cLoc - horizontalOffsets - pwMaps + getStructInfo (FoundStructure swg loc) = (distillLabel swg, loc) --- | We only have to do this once, for the "smallest" chunk occurrence size discrepancy. + validateIntactness2d fs = do + intactness <- lift $ ensureStructureIntact entLoader fs + tell . pure . ChunkIntactnessVerification $ + IntactPlacementLog + intactness + (getName . originalDefinition . structureWithGrid $ fs) + (upperLeftCorner fs) + return intactness + + 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. -generatePossibleOffsets :: (Int, FoundAndExpectedChunkPositions) -> Maybe NEIntSet -generatePossibleOffsets (sizeDifference, FoundAndExpectedChunkPositions found expected) = +seedPossibleOffsets :: (Int, FoundAndExpectedChunkPositions) -> Maybe NEIntSet +seedPossibleOffsets (sizeDifference, FoundAndExpectedChunkPositions found expected) = NEIS.nonEmptySet $ IS.fromList possibleOffsets where possibleOffsets = @@ -298,23 +337,12 @@ isCoveredWithOffset :: FoundAndExpectedChunkPositions -> Int -> Bool isCoveredWithOffset (FoundAndExpectedChunkPositions found expected) offset = NEIS.map (+ offset) expected `NEIS.isSubsetOf` found --- | --- 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. -registerStructureMatches :: +registerBestStructureMatch :: (Monad s, Eq a, Eq b) => - [FoundStructure b a] -> + Maybe (FoundStructure b a) -> RecognitionState b a -> - WriterT [SearchLog a] s (RecognitionState b a) -registerStructureMatches unrankedCandidates oldState = do - tell $ pure newMsg + s (RecognitionState b a) +registerBestStructureMatch maybeValidCandidate oldState = return $ oldState - & foundStructures %~ maybe id addFound (listToMaybe rankedCandidates) - where - -- Sorted by decreasing order of preference. - rankedCandidates = sortOn Down unrankedCandidates - - getStructInfo (FoundStructure swg _) = distillLabel swg - newMsg = FoundCompleteStructureCandidates $ map getStructInfo rankedCandidates + & foundStructures %~ maybe id addFound maybeValidCandidate diff --git a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Type.hs b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Type.hs index b5b45b11d..130d46ceb 100644 --- a/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Type.hs +++ b/src/swarm-topography/Swarm/Game/Scenario/Topography/Structure/Recognition/Type.hs @@ -24,6 +24,7 @@ import Data.Aeson (ToJSON) import Data.Function (on) import Data.HashMap.Strict (HashMap) import Data.Int (Int32) +import Data.IntSet.NonEmpty (NEIntSet) import Data.List.NonEmpty (NonEmpty) import Data.Map (Map) import Data.Maybe (catMaybes) @@ -81,21 +82,24 @@ type SymbolSequence a = [AtomicKeySymbol a] data PositionWithinRow b a = PositionWithinRow { _position :: Int32 -- ^ horizontal index of the entity within the row - , structureRow :: StructureRow b a + , structureRow :: ConsolidatedRowReferences b a } -- | A chunkified version of a structure row. -- Each unique structure row will need to test one of these -- against the world row being examined. data RowChunkMatchingReference b a = RowChunkMatchingReference - { locatableRow :: StructureRow b a + { locatableRows :: ConsolidatedRowReferences b a , confirmationMap :: HashMap (NonEmpty a) (NonEmpty Int) } data PiecewiseRecognition b a = PiecewiseRecognition { piecewiseSM :: StateMachine (AtomicKeySymbol a) (NonEmpty a) - , -- , picewiseLookup :: NonEmpty (HashMap (NonEmpty a) (NonEmpty Int)) - picewiseLookup :: NonEmpty (RowChunkMatchingReference b a) + , picewiseLookup :: NonEmpty (RowChunkMatchingReference b a) + -- ^ A lookup structure for use with results of the + -- Aho-Corasick matcher. This lookup will determine whether + -- the discontiguous "chunks" found by the matcher occur at + -- the right positions with respect to the reference structure. } data PositionedChunk a = PositionedChunk @@ -115,7 +119,7 @@ data PositionedChunk a = PositionedChunk -- -- this record will contain two entries in its 'entityOccurrences' field. data SingleRowEntityOccurrences b a = SingleRowEntityOccurrences - { myRow :: StructureRow b a + { myRow :: ConsolidatedRowReferences b a , myEntity :: a , contiguousChunks :: [PositionedChunk a] , expandedOffsets :: InspectionOffsets @@ -146,6 +150,14 @@ data StructureRow b a = StructureRow , rowContent :: SymbolSequence a } +-- | Represents all rows across all structures that share +-- a particular row content +data ConsolidatedRowReferences b a = ConsolidatedRowReferences + { sharedRowContent :: SymbolSequence a + , referencingRows :: NonEmpty (StructureRow b a) + , theRowWidth :: RowWidth + } + -- | This wrapper facilitates naming the original structure -- (i.e. the "payload" for recognition) -- for the purpose of both UI display and internal uniqueness, @@ -203,27 +215,13 @@ instance Semigroup InspectionOffsets where InspectionOffsets l1 r1 <> InspectionOffsets l2 r2 = InspectionOffsets (l1 <> l2) (r1 <> r2) --- | Each automaton shall be initialized to recognize --- a certain subset of structure rows, that may either --- all be within one structure, or span multiple structures. -data AutomatonInfo en k v = AutomatonInfo +data AutomatonInfo v k = AutomatonInfo { _inspectionOffsets :: InspectionOffsets - , _automaton :: StateMachine k v - , _searchPairs :: NonEmpty ([k], v) - -- ^ these are the tuples input to the 'makeStateMachine' function, - -- for debugging purposes. - } - deriving (Generic) - -makeLenses ''AutomatonInfo - -data AutomatonNewInfo v k = AutomatonNewInfo - { _inspectionOffsets2 :: InspectionOffsets , piecewiseStuff :: PiecewiseRecognition v k } deriving (Generic) -makeLenses ''AutomatonNewInfo +makeLenses ''AutomatonInfo -- | The complete set of data needed to identify applicable -- structures, based on a just-placed entity. @@ -231,7 +229,7 @@ data RecognizerAutomatons b a = RecognizerAutomatons { _originalStructureDefinitions :: Map OriginalName (StructureInfo b a) -- ^ all of the structures that shall participate in automatic recognition. -- This list is used only by the UI and by the 'Floorplan' command. - , _automatonsByEntity :: HashMap a (AutomatonNewInfo b a) + , _automatonsByEntity :: HashMap a (AutomatonInfo b a) } deriving (Generic) @@ -248,6 +246,34 @@ data FoundStructure b a = FoundStructure } deriving (Eq) +data FoundRowFromChunk a = FoundRowFromChunk + { chunkOffsetFromSearchBorder :: Int + , horizontalStructPos :: Int32 + , chunkStructure :: a + } + deriving (Functor, Generic, ToJSON) + +-- | The located occurrences of a specific contiguous chunk of entities. +-- Note that an identical chunk may recur more than once in a structure row. +-- This record represents all of the recurrences of one such chunk. +-- +-- Any different chunks contained within a row will be described by +-- their own instance of this record. +-- +-- Note: By virtue of the searching algorithm, these indices +-- are expected to automatically be in sorted order +data FoundAndExpectedChunkPositions = FoundAndExpectedChunkPositions + { foundPositions :: NEIntSet + , expectedPositions :: NEIntSet + } + deriving (Generic, ToJSON) + +data ChunkedRowMatch a e = ChunkedRowMatch + { positionsComparison :: [(FoundAndExpectedChunkPositions, NonEmpty e)] + , foundChunkRow :: FoundRowFromChunk a + } + deriving (Functor, Generic, ToJSON) + -- | Ordering is by increasing preference between simultaneously -- completed structures. -- The preference heuristic is for: diff --git a/test/integration/Main.hs b/test/integration/Main.hs index 73525360e..3da310d9c 100644 --- a/test/integration/Main.hs +++ b/test/integration/Main.hs @@ -473,7 +473,7 @@ testScenarioSolutions rs ui key = , testSolution Default "Testing/1575-structure-recognizer/1644-rotated-recognition" , testSolution Default "Testing/1575-structure-recognizer/1644-rotated-preplacement-recognition" , testSolution Default "Testing/1575-structure-recognizer/2115-encroaching-upon-interior-transparent-cells" - , testSolution Default "Testing/1575-structure-recognizer/2115-encroaching-upon-exterior-transparent-cells" + , testSolution Default "Testing/1575-structure-recognizer/2201-piecewise-lines" ] ] , testSolution' Default "Testing/1430-built-robot-ownership" CheckForBadErrors $ \g -> do