packages feed

keiki-0.3.0.0: test/Keiki/ReplayOnlySpec.hs

{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE QualifiedDo #-}

-- | EP-6 (keiro MasterPlan 24): 'ReplayOnly' edges. A replay-only edge
-- is excluded from forward stepping and participates in inversion only
-- when no 'Live' edge attributes the observed event. The fixture is the
-- keiki-level shape of the "black-acuity" guard-tightening scenario:
-- machine A confirms any reservation; machine B tightens the guard to
-- non-black acuity; the replay-only twin carries the removed region
-- (@acuityBlack == True@) so history stays replayable.
module Keiki.ReplayOnlySpec (spec) where

import Data.Proxy (Proxy (..))
import Keiki.Builder ((.=))
import Keiki.Builder qualified as B
import Keiki.Core
import Test.Hspec

-- * Fixture: the black-acuity scenario at the keiki level ------------------

data DivertCmd = ConfirmReservation Bool
  deriving stock (Eq, Show)

data DivertEvent = ReservationConfirmed Bool
  deriving stock (Eq, Show)

data DivertVertex = Held | Confirmed
  deriving stock (Eq, Ord, Show, Enum, Bounded)

type DivertRegs = '[ '("wasBlack", Bool)]

inCtorConfirm :: InCtor DivertCmd '[ '("acuityBlack", Bool)]
inCtorConfirm =
  InCtor
    { icName = "ConfirmReservation",
      icMatch = \case
        ConfirmReservation b -> Just (RCons (Proxy @"acuityBlack") b RNil),
      icBuild = \(RCons _ b RNil) -> ConfirmReservation b
    }

wireConfirmed :: WireCtor DivertEvent (Bool, ())
wireConfirmed =
  WireCtor
    { wcName = "ReservationConfirmed",
      wcMatch = \case
        ReservationConfirmed b -> Just (b, ()),
      wcBuild = \(b, ()) -> ReservationConfirmed b
    }

initialDivertRegs :: RegFile DivertRegs
initialDivertRegs = RCons (Proxy @"wasBlack") False RNil

acuityRead :: Term DivertRegs DivertCmd '[ '("acuityBlack", Bool)] Bool
acuityRead = inpCtor inCtorConfirm ZIdx

confirmOut :: OutTerm DivertRegs DivertCmd DivertEvent
confirmOut = pack inCtorConfirm wireConfirmed (OFCons acuityRead OFNil)

recordAcuity :: Update DivertRegs '["wasBlack"] DivertCmd
recordAcuity = USet (#wasBlack :: IndexN "wasBlack" DivertRegs Bool) acuityRead

-- | The old rule: confirm any reservation.
oldGuard :: HsPred DivertRegs DivertCmd
oldGuard = matchInCtor inCtorConfirm

-- | The tightened rule: confirm only non-black acuity.
newGuard :: HsPred DivertRegs DivertCmd
newGuard = PAnd (matchInCtor inCtorConfirm) (PEq acuityRead (TLit False))

-- | The removed region, @old ∧ ¬new@: exactly black acuity.
removedRegionGuard :: HsPred DivertRegs DivertCmd
removedRegionGuard = PAnd (matchInCtor inCtorConfirm) (PEq acuityRead (TLit True))

confirmEdge :: HsPred DivertRegs DivertCmd -> EdgeMode -> Edge (HsPred DivertRegs DivertCmd) DivertRegs DivertCmd DivertEvent DivertVertex
confirmEdge g m =
  Edge
    { guard = g,
      update = recordAcuity,
      output = [confirmOut],
      target = Confirmed,
      mode = m
    }

divertMachine ::
  [Edge (HsPred DivertRegs DivertCmd) DivertRegs DivertCmd DivertEvent DivertVertex] ->
  SymTransducer (HsPred DivertRegs DivertCmd) DivertRegs DivertVertex DivertCmd DivertEvent
divertMachine heldEdges =
  SymTransducer
    { edgesOut = \case
        Held -> heldEdges
        Confirmed -> [],
      initial = Held,
      initialRegs = initialDivertRegs,
      isFinal = (== Confirmed)
    }

-- | Machine A: the original rule, before tightening.
machineA :: SymTransducer (HsPred DivertRegs DivertCmd) DivertRegs DivertVertex DivertCmd DivertEvent
machineA = divertMachine [confirmEdge oldGuard Live]

-- | Machine B without the twin: the tightened rule alone. Events
-- written under the old rule in the removed region lose their
-- inverting edge.
machineBBad :: SymTransducer (HsPred DivertRegs DivertCmd) DivertRegs DivertVertex DivertCmd DivertEvent
machineBBad = divertMachine [confirmEdge newGuard Live]

-- | Machine B with the replay-only twin carrying the removed region.
machineBGood :: SymTransducer (HsPred DivertRegs DivertCmd) DivertRegs DivertVertex DivertCmd DivertEvent
machineBGood =
  divertMachine
    [ confirmEdge newGuard Live,
      confirmEdge removedRegionGuard ReplayOnly
    ]

-- | A sloppy twin whose guard overlaps the live edge (it kept the whole
-- old guard instead of the complement). Its update is deliberately
-- distinguishable (constant 'True') so a test can tell which edge
-- applied during replay.
machineOverlap :: SymTransducer (HsPred DivertRegs DivertCmd) DivertRegs DivertVertex DivertCmd DivertEvent
machineOverlap =
  divertMachine
    [ confirmEdge newGuard Live,
      Edge
        { guard = oldGuard,
          update = USet (#wasBlack :: IndexN "wasBlack" DivertRegs Bool) (TLit True),
          output = [confirmOut],
          target = Confirmed,
          mode = ReplayOnly
        }
    ]

-- | Two replay-only edges competing for the same wire constructor with
-- overlapping guards: same-mode ambiguity must still be caught.
machineTwinClash :: SymTransducer (HsPred DivertRegs DivertCmd) DivertRegs DivertVertex DivertCmd DivertEvent
machineTwinClash =
  divertMachine
    [ confirmEdge oldGuard ReplayOnly,
      confirmEdge oldGuard ReplayOnly
    ]

-- | Both overlap edges live: the pre-existing checks must still flag.
machineLiveClash :: SymTransducer (HsPred DivertRegs DivertCmd) DivertRegs DivertVertex DivertCmd DivertEvent
machineLiveClash =
  divertMachine
    [ confirmEdge newGuard Live,
      confirmEdge oldGuard Live
    ]

-- | 'Confirmed' is reachable ONLY through the replay-only edge, and has
-- an outgoing live edge. Replay continuation makes that vertex live
-- (an old stream replays through the twin, then serves new commands),
-- so the dead-edge check must not flag its outgoing edges.
machineTwinOnlyPath :: SymTransducer (HsPred DivertRegs DivertCmd) DivertRegs DivertVertex DivertCmd DivertEvent
machineTwinOnlyPath =
  SymTransducer
    { edgesOut = \case
        Held -> [confirmEdge removedRegionGuard ReplayOnly]
        -- A live self-loop at the vertex reachable only via the twin.
        Confirmed -> [confirmEdge oldGuard Live],
      initial = Held,
      initialRegs = initialDivertRegs,
      isFinal = (== Confirmed)
    }

blackHistory :: [DivertEvent]
blackHistory = [ReservationConfirmed True]

-- * Builder surface --------------------------------------------------------

-- | The same twin-bearing machine written through "Keiki.Builder",
-- marking the twin with 'B.replayOnly'.
builderMachine :: SymTransducer (HsPred DivertRegs DivertCmd) DivertRegs DivertVertex DivertCmd DivertEvent
builderMachine =
  B.buildTransducer Held initialDivertRegs (== Confirmed) $ Prelude.do
    B.from Held Prelude.do
      B.onCmd inCtorConfirm $ \d -> B.do
        B.requireEq d.acuityBlack (TLit False)
        B.slot @"wasBlack" .= d.acuityBlack
        B.emit wireConfirmed (d.acuityBlack B.*: B.oNil)
        B.goto Confirmed
      B.onCmd inCtorConfirm $ \d -> B.do
        B.replayOnly
        B.requireEq d.acuityBlack (TLit True)
        B.slot @"wasBlack" .= d.acuityBlack
        B.emit wireConfirmed (d.acuityBlack B.*: B.oNil)
        B.goto Confirmed
    B.from Confirmed (Prelude.pure ())

-- * Specs ------------------------------------------------------------------

spec :: Spec
spec = do
  describe "forward stepping" $ do
    it "machine A accepts a black-acuity confirmation and emits its event" $
      case stepEither machineA (Held, initialDivertRegs) (ConfirmReservation True) of
        Right (v, regs, outs) -> do
          v `shouldBe` Confirmed
          regs ! #wasBlack `shouldBe` True
          outs `shouldBe` blackHistory
        Left failure -> expectationFailure ("machine A rejected: " <> show failure)

    it "never selects a ReplayOnly edge even when its guard models the command" $
      case stepEither machineBGood (Held, initialDivertRegs) (ConfirmReservation True) of
        Left (NoMatchingEdge Held rejected) ->
          -- Both edges are reported rejected: the live edge by its
          -- guard, the twin by its mode.
          map (edgeIndex . rejectedEdge) rejected `shouldBe` [0, 1]
        Left other -> expectationFailure ("expected NoMatchingEdge, got " <> show other)
        Right (v, _, outs) ->
          expectationFailure ("expected rejection, stepped to " <> show (v, outs))

    it "delta and omega agree that the removed region is rejected" $ do
      case delta machineBGood Held initialDivertRegs (ConfirmReservation True) of
        Nothing -> pure ()
        Just (v, _) -> expectationFailure ("delta stepped to " <> show v)
      omega machineBGood Held initialDivertRegs (ConfirmReservation True)
        `shouldBe` []

    it "still accepts commands under the tightened live rule" $
      case stepEither machineBGood (Held, initialDivertRegs) (ConfirmReservation False) of
        Right (v, regs, outs) -> do
          v `shouldBe` Confirmed
          regs ! #wasBlack `shouldBe` False
          outs `shouldBe` [ReservationConfirmed False]
        Left failure -> expectationFailure ("live edge rejected: " <> show failure)

    it "reports NoMatchingEdge (not NoOutgoingEdges) at a replay-only-only vertex" $
      case stepEither machineTwinOnlyPath (Held, initialDivertRegs) (ConfirmReservation True) of
        Left (NoMatchingEdge Held [rejected]) ->
          rejectedTarget rejected `shouldBe` Confirmed
        Left other -> expectationFailure ("expected one rejected edge, got " <> show other)
        Right (v, _, outs) ->
          expectationFailure ("expected rejection, stepped to " <> show (v, outs))

  describe "two-phase inversion" $ do
    it "black-acuity history fails to invert without the twin" $
      case reconstituteEither machineBBad blackHistory of
        Left failure -> do
          replayFailedIndex failure `shouldBe` 0
          case replayFailureReason failure of
            ReplayEventFailed (ReplayNoInvertingEdge Held _) -> pure ()
            other ->
              expectationFailure ("expected ReplayNoInvertingEdge, got " <> show other)
        Right result ->
          expectationFailure ("expected replay failure, got " <> show (fst result))

    it "black-acuity history inverts through the twin, applying its writes" $
      case reconstituteEither machineBGood blackHistory of
        Right (v, regs) -> do
          v `shouldBe` Confirmed
          regs ! #wasBlack `shouldBe` True
        Left failure -> expectationFailure ("twin replay failed: " <> show failure)

    it "replays machine A's history identically under the twin-bearing machine" $
      case (reconstituteEither machineA blackHistory, reconstituteEither machineBGood blackHistory) of
        (Right (vA, regsA), Right (vB, regsB)) ->
          (vB, regsB ! #wasBlack) `shouldBe` (vA, regsA ! #wasBlack)
        (Left failure, _) -> expectationFailure ("machine A replay failed: " <> show failure)
        (_, Left failure) -> expectationFailure ("machine B replay failed: " <> show failure)

    it "prefers the live edge when a sloppy twin's guard overlaps it" $
      case reconstituteEither machineOverlap [ReservationConfirmed False] of
        Right (v, regs) -> do
          v `shouldBe` Confirmed
          -- The live edge writes the event's own acuity (False); the
          -- sloppy twin would have written the constant True. Live wins.
          regs ! #wasBlack `shouldBe` False
        Left failure ->
          expectationFailure ("expected live-phase attribution: " <> show failure)

    it "falls through to the sloppy twin only for unattributable history" $
      case reconstituteEither machineOverlap blackHistory of
        Right (v, regs) -> do
          v `shouldBe` Confirmed
          regs ! #wasBlack `shouldBe` True
        Left failure -> expectationFailure ("twin fallthrough failed: " <> show failure)

    it "still reports ambiguity between two replay-only candidates" $
      case reconstituteEither machineTwinClash blackHistory of
        Left failure ->
          case replayFailureReason failure of
            ReplayEventFailed (ReplayAmbiguousInversions Held matchedEdges) ->
              map (edgeIndex . matchedEdge) matchedEdges `shouldBe` [0, 1]
            other ->
              expectationFailure ("expected ReplayAmbiguousInversions, got " <> show other)
        Right result ->
          expectationFailure ("expected ambiguity failure, got " <> show (fst result))

    it "applies the same two-phase preference in letter-only applyEvent" $ do
      case applyEvent machineBBad Held initialDivertRegs (ReservationConfirmed True) of
        Nothing -> pure ()
        Just (v, _) -> expectationFailure ("applyEvent inverted to " <> show v)
      case applyEvent machineBGood Held initialDivertRegs (ReservationConfirmed True) of
        Just (v, regs) -> do
          v `shouldBe` Confirmed
          regs ! #wasBlack `shouldBe` True
        Nothing -> expectationFailure "applyEvent could not use the twin"
      case applyEvent machineOverlap Held initialDivertRegs (ReservationConfirmed False) of
        Just (_, regs) -> regs ! #wasBlack `shouldBe` False
        Nothing -> expectationFailure "applyEvent could not use the live edge"

  describe "static checks" $ do
    it "does not flag a live/replay-only pair as an inversion ambiguity" $
      inversionAmbiguityWarnings machineBGood `shouldBe` []

    it "still flags a same-mode replay-only pair as an inversion ambiguity" $
      case inversionAmbiguityWarnings machineTwinClash of
        [InversionAmbiguity {tvwSource = Held, tvwEdgeA = 0, tvwEdgeB = 1}] -> pure ()
        other -> expectationFailure ("expected one same-mode warning, got " <> show other)

    it "does not flag a live/replay-only guard overlap as nondeterminism" $
      checkTransitionDeterminismPure machineOverlap `shouldBe` []

    it "still flags the same overlap when both edges are live" $
      case checkTransitionDeterminismPure machineLiveClash of
        [DeterminismWarning {dwSource = Held, dwEdgeA = 0, dwEdgeB = 1}] -> pure ()
        other -> expectationFailure ("expected one overlap warning, got " <> show other)

    it "keeps vertices reachable through replay-only edges out of the dead-edge report" $
      checkDeadEdges defaultDeadEdgeOptions machineTwinOnlyPath `shouldBe` []

    it "validates the twin-bearing black-acuity machine clean by default" $
      validateTransducer defaultValidationOptions machineBGood `shouldBe` []

    it "flags machineLiveClash through the default umbrella (control)" $
      validateTransducer defaultValidationOptions machineLiveClash
        `shouldNotBe` []

  describe "Keiki.Builder replayOnly" $ do
    it "marks the edge ReplayOnly and leaves unmarked edges Live" $
      map mode (edgesOut builderMachine Held) `shouldBe` [Live, ReplayOnly]

    it "behaves like the hand-written twin machine" $ do
      case stepEither builderMachine (Held, initialDivertRegs) (ConfirmReservation True) of
        Left (NoMatchingEdge Held _) -> pure ()
        Left other -> expectationFailure ("expected forward rejection, got " <> show other)
        Right (v, _, outs) ->
          expectationFailure ("expected rejection, stepped to " <> show (v, outs))
      case reconstituteEither builderMachine blackHistory of
        Right (v, regs) -> do
          v `shouldBe` Confirmed
          regs ! #wasBlack `shouldBe` True
        Left failure -> expectationFailure ("builder twin replay failed: " <> show failure)

  describe "EdgeMode combination" $ do
    it "is Live only when every component is Live" $ do
      Live <> Live `shouldBe` Live
      Live <> ReplayOnly `shouldBe` ReplayOnly
      ReplayOnly <> Live `shouldBe` ReplayOnly
      ReplayOnly <> ReplayOnly `shouldBe` ReplayOnly
      mempty `shouldBe` Live