packages feed

microecta-0.1.0.0: src/Data/ECTA/Internal/Paths.hs

{-# LANGUAGE OverloadedStrings #-}

{- | Representations of paths in an FTA, data structures for
  equality constraints over paths, algorithms for saturating these constraints
-}
module Data.ECTA.Internal.Paths (
    Path (.., EmptyPath, ConsPath),
    unPath,
    path,
    Pathable (..),
    pathHeadUnsafe,
    pathTailUnsafe,
    isSubpath,
    isStrictSubpath,
    substSubpath,
    getMaxNonemptyIndex,
    PathTrie (..),
    isEmptyPathTrie,
    isTerminalPathTrie,
    toPathTrie,
    fromPathTrie,
    pathTrieDescend,
    PathEClass (PathEClass, ..),
    unPathEClass,
    hasSubsumingMember,
    completedSubsumptionOrdering,
    EqConstraints (.., EmptyConstraints),
    rawMkEqConstraints,
    unsafeGetEclasses,
    hasSubsumingMemberListBased,
    isContradicting,
    mkEqConstraints,
    combineEqConstraints,
    eqConstraintsDescend,
    constraintsAreContradictory,
    constraintsImply,
    subsumptionOrderedEclasses,
    unsafeSubsumptionOrderedEclasses,
) where

import Prelude hiding (round)

import Data.Function (on)
import Data.Hashable (Hashable (..))
import Data.List (groupBy, isSubsequenceOf, nub, sort, sortBy)
import qualified Data.List as List
import Data.Maybe (mapMaybe)
import qualified Data.Text as Text

import Data.Equivalence.Monad (classes, desc, equate, runEquivM)

import Data.Memoization (MemoCacheTag (..), memo2)
import Data.Text.Extended.Pretty
import Utility.Fixpoint

-------------------------------------------------------

-----------------------------------------------------------------------
--------------------------- Misc / general ----------------------------
-----------------------------------------------------------------------

flipOrdering :: Ordering -> Ordering
flipOrdering GT = LT
flipOrdering LT = GT
flipOrdering EQ = EQ

-----------------------------------------------------------------------
-------------------------------- Paths --------------------------------
-----------------------------------------------------------------------

-- | Path into an edge's children, represented as child indexes.
data Path = Path ![Int]
    deriving (Eq, Ord, Show)

-- | Extract the raw child-index list from a @Path@.
unPath :: Path -> [Int]
unPath (Path p) = p

instance Hashable Path where
    hashWithSalt salt (Path components) = salt `hashWithSalt` components

-- | Build a @Path@ from child indexes.
path :: [Int] -> Path
path = Path

{-# COMPLETE EmptyPath, ConsPath #-}

pattern EmptyPath :: Path
pattern EmptyPath = Path []

pattern ConsPath :: Int -> Path -> Path
pattern ConsPath p ps <- Path (p : (Path -> ps))
    where
        ConsPath p (Path ps) = Path (p : ps)

-- | First path component. Unsafe on 'EmptyPath'.
pathHeadUnsafe :: Path -> Int
pathHeadUnsafe EmptyPath = error "pathHeadUnsafe: empty path"
pathHeadUnsafe (ConsPath p _) = p

-- | Path without its first component. Unsafe on 'EmptyPath'.
pathTailUnsafe :: Path -> Path
pathTailUnsafe EmptyPath = error "pathTailUnsafe: empty path"
pathTailUnsafe (ConsPath _ ps) = ps

instance Pretty Path where
    pretty (Path ps) = Text.intercalate "." (map (Text.pack . show) ps)

-- | Whether the first path is a prefix of the second path.
isSubpath :: Path -> Path -> Bool
isSubpath EmptyPath _ = True
isSubpath (ConsPath p1 ps1) (ConsPath p2 ps2)
    | p1 == p2 = isSubpath ps1 ps2
isSubpath _ _ = False

-- | Whether the first path is a strict prefix of the second path.
isStrictSubpath :: Path -> Path -> Bool
isStrictSubpath EmptyPath EmptyPath = False
isStrictSubpath EmptyPath _ = True
isStrictSubpath (ConsPath p1 ps1) (ConsPath p2 ps2)
    | p1 == p2 = isStrictSubpath ps1 ps2
isStrictSubpath _ _ = False

{- | Read `substSubpath p1 p2 p3` as `[p1/p2]p3`

@substSubpath replacement toReplace target@ takes @toReplace@, a prefix of
@target@, and returns a new path in which @toReplace@ has been replaced by
@replacement@.

 Undefined if toReplace is not a prefix of target
-}
substSubpath :: Path -> Path -> Path -> Path
substSubpath replacement toReplace target = Path $ (unPath replacement) ++ drop (length $ unPath toReplace) (unPath target)

--------------------------------------------------------------------------
---------------------------- Using paths ---------------------------------
--------------------------------------------------------------------------

-- | Things that can be inspected or edited by child-index paths.
class Pathable t t' | t -> t' where
    -- | Result type used when a path is absent.
    type Emptyable t'

    -- | Read the value at a path, returning the empty value when absent.
    getPath :: Path -> t -> Emptyable t'

    -- | Read all values reachable at a path.
    getAllAtPath :: Path -> t -> [t']

    -- | Apply a local edit at a path.
    modifyAtPath :: (t' -> t') -> Path -> t -> t

-----------------------------------------------------------------------
---------------------------- Path tries -------------------------------
-----------------------------------------------------------------------

-- | Largest child index present in a trie node, if any.
getMaxNonemptyIndex :: PathTrie -> Maybe Int
getMaxNonemptyIndex EmptyPathTrie = Nothing
getMaxNonemptyIndex TerminalPathTrie = Nothing
getMaxNonemptyIndex (PathTrieSingleChild i _) = Just i
getMaxNonemptyIndex (PathTrie children) = Just $ fst (last children)

---------------------
------- Path tries
---------------------

{- | Trie of paths used to index equality constraints.

Most constraint tries in the original workloads are either empty, terminal, or
one path component wide for many levels.  `PathTrieSingleChild` keeps that hot
case compact. The multi-child case used to be a dense child table, which made
lookup cheap but forced GHC to optimise large recursive structure code. The
sparse representation keeps only non-empty children in sorted order. That
keeps union, ordering, and subsumption as linear merges over present children,
while avoiding the `-O2` compile-time memory blow-up from the dense code.

Invariant for @PathTrie@: children are sorted by component, contain no
@EmptyPathTrie@ entries, and contain at least two children.  Constructors are
exported for tests and compatibility, so functions that rebuild multi-child
tries should restore that invariant before returning.
-}
data PathTrie
    = -- | No paths.
      EmptyPathTrie
    | -- | Exactly the empty path.
      TerminalPathTrie
    | -- | A compact node with exactly one child at the given path component.
      PathTrieSingleChild {-# UNPACK #-} !Int !PathTrie
    | -- | Sparse multi-child node. See the invariant on @PathTrie@.
      PathTrie ![(Int, PathTrie)]
    deriving (Eq, Show)

instance Hashable PathTrie where
    hashWithSalt salt EmptyPathTrie = salt `hashWithSalt` (0 :: Int)
    hashWithSalt salt TerminalPathTrie = salt `hashWithSalt` (1 :: Int)
    hashWithSalt salt (PathTrieSingleChild i pt) =
        salt `hashWithSalt` (2 :: Int) `hashWithSalt` i `hashWithSalt` pt
    hashWithSalt salt (PathTrie children) =
        List.foldl' hashWithSalt (salt `hashWithSalt` (3 :: Int)) children

-- | Check for the trie containing no paths.
isEmptyPathTrie :: PathTrie -> Bool
isEmptyPathTrie EmptyPathTrie = True
isEmptyPathTrie _ = False

-- | Check for the trie containing exactly the empty path.
isTerminalPathTrie :: PathTrie -> Bool
isTerminalPathTrie TerminalPathTrie = True
isTerminalPathTrie _ = False

-- | Whether a trie contains at least two distinct paths.
pathTrieHasAtLeastTwoPaths :: PathTrie -> Bool
pathTrieHasAtLeastTwoPaths = go False
  where
    go :: Bool -> PathTrie -> Bool
    go _ EmptyPathTrie = False
    go seenOne TerminalPathTrie = seenOne
    go seenOne (PathTrieSingleChild _ pt) = go seenOne pt
    go seenOne (PathTrie children) = goChildren seenOne children

    goChildren :: Bool -> [(Int, PathTrie)] -> Bool
    goChildren _ [] = False
    goChildren seenOne ((_, pt) : rest)
        | go seenOne pt = True
        | pathTrieHasAnyPath pt =
            if seenOne
                then True
                else goChildren True rest
        | otherwise = goChildren seenOne rest

    pathTrieHasAnyPath :: PathTrie -> Bool
    pathTrieHasAnyPath EmptyPathTrie = False
    pathTrieHasAnyPath TerminalPathTrie = True
    pathTrieHasAnyPath (PathTrieSingleChild _ pt) = pathTrieHasAnyPath pt
    pathTrieHasAnyPath (PathTrie children) = any (pathTrieHasAnyPath . snd) children

-- | Compare sparse child lists as if they were dense vectors with empty cells.
comparePathTrieChildren :: [(Int, PathTrie)] -> [(Int, PathTrie)] -> Ordering
comparePathTrieChildren [] [] = EQ
comparePathTrieChildren [] _ = LT
comparePathTrieChildren _ [] = GT
comparePathTrieChildren ((i1, pt1) : rest1) ((i2, pt2) : rest2) =
    case compare i1 i2 of
        LT -> LT
        GT -> GT
        EQ -> case compare pt1 pt2 of
            EQ -> comparePathTrieChildren rest1 rest2
            res -> res

instance Ord PathTrie where
    compare EmptyPathTrie EmptyPathTrie = EQ
    compare EmptyPathTrie _ = LT
    compare _ EmptyPathTrie = GT
    compare TerminalPathTrie TerminalPathTrie = EQ
    compare TerminalPathTrie _ = LT
    compare _ TerminalPathTrie = GT
    compare (PathTrieSingleChild i1 pt1) (PathTrieSingleChild i2 pt2)
        | i1 < i2 = LT
        | i1 > i2 = GT
        | otherwise = compare pt1 pt2
    compare (PathTrieSingleChild i1 pt1) (PathTrie ((i2, pt2) : _)) =
        case compare i1 i2 of
            LT -> LT
            GT -> GT
            EQ -> case compare pt1 pt2 of
                LT -> LT
                GT -> GT
                EQ -> LT -- children2 must have a second nonempty
    compare (PathTrieSingleChild _ _) (PathTrie []) =
        error "compare: invalid empty PathTrie children"
    compare a@(PathTrie _) b@(PathTrieSingleChild _ _) = flipOrdering $ compare b a
    compare (PathTrie children1) (PathTrie children2) = comparePathTrieChildren children1 children2

-- | Precondition: No path in the input is a subpath of another
toPathTrie :: [Path] -> PathTrie
toPathTrie [] = EmptyPathTrie
toPathTrie [EmptyPath] = TerminalPathTrie
toPathTrie ps@(firstPath : _) =
    if all (\p -> pathHeadUnsafe p == pathHeadUnsafe firstPath) ps
        then
            PathTrieSingleChild (pathHeadUnsafe firstPath) (toPathTrie $ map pathTailUnsafe ps)
        else
            PathTrie children
  where
    groups =
        groupBy ((==) `on` pathHeadUnsafe) $
            sortBy (compare `on` pathHeadUnsafe) ps

    children =
        [ (pathHeadUnsafe groupHead, toPathTrie $ map pathTailUnsafe group)
        | group@(groupHead : _) <- groups
        ]

-- | Convert a trie back to its sorted path list.
fromPathTrie :: PathTrie -> [Path]
fromPathTrie EmptyPathTrie = []
fromPathTrie TerminalPathTrie = [EmptyPath]
fromPathTrie (PathTrieSingleChild i pt) = map (ConsPath i) $ fromPathTrie pt
fromPathTrie (PathTrie children) =
    concatMap (\(i, pt) -> map (ConsPath i) $ fromPathTrie pt) children

-- | Descend through one child index, returning 'EmptyPathTrie' if absent.
pathTrieDescend :: PathTrie -> Int -> PathTrie
pathTrieDescend EmptyPathTrie _ = EmptyPathTrie
pathTrieDescend TerminalPathTrie _ = EmptyPathTrie
pathTrieDescend (PathTrie children) i =
    case lookup i children of
        Nothing -> EmptyPathTrie
        Just pt -> pt
pathTrieDescend (PathTrieSingleChild j pt') i
    | i == j = pt'
    | otherwise = EmptyPathTrie

--------------------------------------------------------------------------
---------------------- Equality constraints over paths -------------------
--------------------------------------------------------------------------

---------------------------
---------- Path E-classes
---------------------------

{- | Equality class of paths.

The trie drives subsumption and descent; the path list keeps the older public
API and reduction code cheap to read. Values built by @PathEClass@ and
@mkPathEClassFromPathTrie@ keep the two views consistent.
-}
data PathEClass = PathEClass'
    { getPathTrie :: !PathTrie
    , getOrigPaths :: [Path]
    }
    deriving (Show)

instance Eq PathEClass where
    (==) = (==) `on` getPathTrie

instance Ord PathEClass where
    compare = compare `on` getPathTrie

-- | Build or match an equality class from its sorted path list view.
pattern PathEClass :: [Path] -> PathEClass
pattern PathEClass ps <- PathEClass' _ ps
    where
        PathEClass ps = PathEClass' (toPathTrie $ nub ps) (sort $ nub ps)

-- | Extract the paths in an equality class.
unPathEClass :: PathEClass -> [Path]
unPathEClass (PathEClass' _ paths) = paths

instance Pretty PathEClass where
    pretty pec = "{" <> (Text.intercalate "=" $ map pretty $ unPathEClass pec) <> "}"

instance Hashable PathEClass where
    hashWithSalt salt = hashWithSalt salt . getPathTrie

-- | Build an equality class from a trie, deriving the path list lazily.
mkPathEClassFromPathTrie :: PathTrie -> PathEClass
mkPathEClassFromPathTrie pt = PathEClass' pt (fromPathTrie pt)

-- | Whether one path in the first class strictly subsumes one path in the second.
hasSubsumingMember :: PathEClass -> PathEClass -> Bool
hasSubsumingMember pec1 pec2 = go (getPathTrie pec1) (getPathTrie pec2)
  where
    go :: PathTrie -> PathTrie -> Bool
    go EmptyPathTrie _ = False
    go _ EmptyPathTrie = False
    go TerminalPathTrie TerminalPathTrie = False
    go TerminalPathTrie _ = True
    go _ TerminalPathTrie = False
    go (PathTrieSingleChild i1 pt1) (PathTrieSingleChild i2 pt2) =
        if i1 == i2
            then
                go pt1 pt2
            else
                False
    go (PathTrieSingleChild i1 pt1) (PathTrie children2) = case lookup i1 children2 of
        Nothing -> False
        Just pt2 -> go pt1 pt2
    go (PathTrie children1) (PathTrieSingleChild i2 pt2) = case lookup i2 children1 of
        Nothing -> False
        Just pt1 -> go pt1 pt2
    go (PathTrie children1) (PathTrie children2) = anyMatchingChild children1 children2

    -- Both child lists are sorted, so this keeps the old dense-table behaviour
    -- without scanning absent indexes or doing repeated linear lookups.
    anyMatchingChild [] _ = False
    anyMatchingChild _ [] = False
    anyMatchingChild left@((i1, pt1) : rest1) right@((i2, pt2) : rest2) =
        case compare i1 i2 of
            LT -> anyMatchingChild rest1 right
            GT -> anyMatchingChild left rest2
            EQ -> go pt1 pt2 || anyMatchingChild rest1 rest2

{- | Total ordering used when choosing constraint-propagation order.

Strict subsumption comes first: if one equality class contains a path that is a
strict prefix of a path in another class, the shorter one must be processed
before the longer one. Incomparable classes use the reversed trie ordering.
That tie-break keeps term-search-shaped workloads in the old left-to-right
propagation order, which avoids extra reduction work in practice.
-}
completedSubsumptionOrdering :: PathEClass -> PathEClass -> Ordering
completedSubsumptionOrdering pec1 pec2
    | hasSubsumingMember pec1 pec2 = LT
    | hasSubsumingMember pec2 pec1 = GT
    | otherwise = compare pec2 pec1

--------------------------------
---------- Equality constraints
--------------------------------

-- | Equality constraints attached to an ECTA edge.
data EqConstraints
    = EqConstraints
        { getEclasses :: [PathEClass]
        -- ^ Must be sorted
        }
    | EqContradiction
    deriving (Eq, Ord, Show)

instance Hashable EqConstraints where
    hashWithSalt salt (EqConstraints eclasses) =
        salt `hashWithSalt` (0 :: Int) `hashWithSalt` eclasses
    hashWithSalt salt EqContradiction =
        salt `hashWithSalt` (1 :: Int)

instance Pretty EqConstraints where
    pretty ecs = "{" <> (Text.intercalate "," $ map pretty (getEclasses ecs)) <> "}"

--------- Destructors and patterns

-- | Unsafe. Internal use only
ecsGetPaths :: EqConstraints -> [[Path]]
ecsGetPaths = map unPathEClass . getEclasses

pattern EmptyConstraints :: EqConstraints
pattern EmptyConstraints = EqConstraints []

-- | Extract equality classes, failing on 'EqContradiction'.
unsafeGetEclasses :: EqConstraints -> [PathEClass]
unsafeGetEclasses EqContradiction = error "unsafeGetEclasses: Illegal argument 'EqContradiction'"
unsafeGetEclasses ecs = getEclasses ecs

-- | Construct constraints without congruence closure or contradiction checks.
rawMkEqConstraints :: [[Path]] -> EqConstraints
rawMkEqConstraints = EqConstraints . map PathEClass

-- | Check whether a constraint set is already contradictory.
constraintsAreContradictory :: EqConstraints -> Bool
constraintsAreContradictory = (== EqContradiction)

--------- Construction

-- | List-based reference implementation for 'hasSubsumingMember'.
hasSubsumingMemberListBased :: [Path] -> [Path] -> Bool
hasSubsumingMemberListBased ps1 ps2 =
    any (\p1 -> any (isStrictSubpath p1) ps2) ps1

{- | Check whether a normalized path class forces a path equal to its subpath.

After congruence closure, every subsumption cycle appears as an equality class
containing both a path and one of its strict prefixes. Such a class is
unsatisfiable for finite trees: it would require a subterm to be equal to a
proper descendant of itself.
-}
isContradicting :: [[Path]] -> Bool
isContradicting cs = any (\pec -> hasSubsumingMemberListBased pec pec) cs

{- | Build normalized equality constraints.

This performs equality-class completion, adds path congruences, and detects
contradictions caused by a path being forced equal to one of its strict
subpaths. The implementation is intentionally direct rather than clever because
constraint construction is not the main @microecta@ API boundary, and the
@equivalence@ package keeps this path fast enough for current workloads.
-}
mkEqConstraints :: [[Path]] -> EqConstraints
mkEqConstraints initialConstraints = case completedConstraints of
    Nothing -> EqContradiction
    Just cs -> EqConstraints $ sort $ map PathEClass cs
  where
    removeTrivial :: (Eq a) => [[a]] -> [[a]]
    removeTrivial = filter (\x -> length x > 1) . map nub

    -- Reason for the extra "complete" in this line:
    -- The first simplification done to the constraints is eclass-completion,
    -- to remove redundancy and shrink things before the very inefficient
    -- addCongruences step (important in tests; less so in realistic input).
    -- The last simplification must also be completion, to give a valid value.
    completedConstraints = fixMaybe round $ complete $ removeTrivial initialConstraints

    round :: [[Path]] -> Maybe [[Path]]
    round cs =
        let cs' = addCongruences cs
            cs'' = complete cs'
         in if isContradicting cs''
                then
                    Nothing
                else
                    Just cs''

    addCongruences :: [[Path]] -> [[Path]]
    addCongruences cs = cs ++ [map (\z -> substSubpath z x y) left | left <- cs, right <- cs, x <- left, y <- right, isStrictSubpath x y]

    assertEquivs [] = return []
    assertEquivs (x : xs) = mapM (equate x) xs

    complete :: (Ord a) => [[a]] -> [[a]]
    complete initialClasses = runEquivM (: []) (++) $ do
        mapM_ assertEquivs initialClasses
        mapM desc =<< classes

---------- Operations

-- | Combine two constraint sets and normalize the result.
combineEqConstraints :: EqConstraints -> EqConstraints -> EqConstraints
combineEqConstraints EqContradiction _ = EqContradiction
combineEqConstraints _ EqContradiction = EqContradiction
combineEqConstraints EmptyConstraints EmptyConstraints = EmptyConstraints
combineEqConstraints ec1 ec2 = combineEqConstraintsMemo ec1 ec2
{-# NOINLINE combineEqConstraints #-}

combineEqConstraintsMemo :: EqConstraints -> EqConstraints -> EqConstraints
combineEqConstraintsMemo = memo2 (NameTag "combineEqConstraints") go
  where
    go ec1 ec2 = mkEqConstraints $ ecsGetPaths ec1 ++ ecsGetPaths ec2
{-# NOINLINE combineEqConstraintsMemo #-}

{- | Descend every path in a constraint set through one child index.

Equality classes with fewer than two remaining paths are dropped immediately:
they no longer constrain anything after the descent.
-}
eqConstraintsDescend :: EqConstraints -> Int -> EqConstraints
eqConstraintsDescend EqContradiction _ = EqContradiction
eqConstraintsDescend EmptyConstraints _ = EmptyConstraints
eqConstraintsDescend ecs i = case mapMaybe (`pathEClassDescendNontrivial` i) (getEclasses ecs) of
    [] -> EmptyConstraints
    [eclass] -> EqConstraints [eclass]
    eclasses -> EqConstraints $ sort eclasses
  where
    pathEClassDescendNontrivial (PathEClass' pt _) childIndex =
        let pt' = pathTrieDescend pt childIndex
         in if pathTrieHasAtLeastTwoPaths pt'
                then Just (mkPathEClassFromPathTrie pt')
                else Nothing

{- | Conservative implication check between two constraint sets.

This is intentionally cheaper than rebuilding the combined closure: every
class required by the second set must occur as a subsequence of some class in
the first set. That is sufficient for redundant-edge pruning, but it is not a
complete theorem prover for arbitrary constraint implication.
-}
constraintsImply :: EqConstraints -> EqConstraints -> Bool
constraintsImply EqContradiction _ = True
constraintsImply _ EqContradiction = False
constraintsImply ecs1 ecs2 = all (\cs -> any (isSubsequenceOf cs) (ecsGetPaths ecs1)) (ecsGetPaths ecs2)

-- | Equality classes sorted for constraint propagation, if not contradictory.
subsumptionOrderedEclasses :: EqConstraints -> Maybe [PathEClass]
subsumptionOrderedEclasses ecs = case ecs of
    EqContradiction -> Nothing
    EqConstraints pecs -> Just $ sortBy completedSubsumptionOrdering pecs

-- | Variant of 'subsumptionOrderedEclasses' that fails on contradiction.
unsafeSubsumptionOrderedEclasses :: EqConstraints -> [PathEClass]
unsafeSubsumptionOrderedEclasses (EqConstraints pecs) = sortBy completedSubsumptionOrdering pecs
unsafeSubsumptionOrderedEclasses EqContradiction = error $ "unsafeSubsumptionOrderedEclasses: unexpected EqContradiction"