keiki-0.1.0.0: test/Keiki/CompositionFeedback1Spec.hs
{-# LANGUAGE TemplateHaskell #-}
-- | Acceptance tests for 'Keiki.Composition.feedback1' under EP-26
-- of MasterPlan 8. The fixture is a tiny aggregate ↔ policy loop:
--
-- ToggleAgg ↔ TogglePolicy
--
-- The aggregate is a two-vertex toggle (Off/On) whose only command
-- 'TgFlip' alternates the vertex and emits a 'TgFlipped' event
-- carrying the same 'tValue' the command supplied. The policy is a
-- stateless one-vertex echo: on every observed 'TgFlipped' event it
-- emits another 'TgFlip' command, forwarding the value field.
--
-- The composite is @feedback1 toggleAgg togglePolicy@. Per the
-- design record at
-- @docs/research/composition-combinators-design.md@'s "`feedback1` —
-- admitted (single-step reduction)" section, this is implemented as
-- @compose t (compose f t)@: t consumes the external command, f
-- maps the resulting event back to a follow-up command, and a
-- second copy of t consumes that follow-up.
--
-- Both halves have empty register files (@'[]@). This is required
-- by 'feedback1''s
-- @Disjoint (Names rs1) (Names (Append rs2 rs1))@ constraint —
-- only @rs1 = '[]@ satisfies it (see EP-26's Decision Log entry
-- dated 2026-05-03). The aggregate's state lives entirely in the
-- vertex.
--
-- The cascade is observable from the composite vertex: after one
-- external command from the initial vertex
-- @Composite Off (Composite Pol Off)@, the composite advances to
-- @Composite On (Composite Pol On)@ — both the outer and the inner
-- copies of t have transitioned, proving the policy's emitted
-- command was consumed by the second t. The output's 'tValue' is
-- the original input's 'tValue', forwarded through the cascade by
-- structural substitution.
module Keiki.CompositionFeedback1Spec
( spec,
-- Re-exported for "Keiki.Render.MermaidSpec" (EP-33 M6). See
-- @docs/plans/33-shape-aware-mermaid-renderers-for-alternative-and-feedback1-composites.md@'s
-- IP-5 reference.
toggleAgg,
togglePolicy,
ToggleVertex (..),
PolicyVertex (..),
loop,
)
where
import GHC.Generics (Generic)
import Keiki.Composition (Composite (..), feedback1)
import Keiki.Core
import Keiki.Generics (Append, emptyRegFile)
import Keiki.Generics.TH (deriveAggregateCtors, deriveWireCtors)
import Keiki.Symbolic (isSingleValuedSym, withSymPred)
import Test.Hspec
-- * The Toggle aggregate fixture (stateless) ------------------------------
-- | Single-field payload carried by both the command and event.
-- Forwarded verbatim through every cascade hop, so the composite's
-- final 'TgFlipped' value matches the original 'TgFlip' value.
data TgPayload = TgPayload
{ tValue :: Int
}
deriving stock (Eq, Show, Generic)
-- | Single-constructor command type with a record payload (the
-- shape 'deriveAggregateCtors' / 'deriveWireCtors' expect).
data TgCmd = TgFlip TgPayload
deriving stock (Eq, Show, Generic)
-- | Single-constructor event type with the same record payload.
data TgEv = TgFlipped TgPayload
deriving stock (Eq, Show, Generic)
-- | Two-vertex toggle: each TgFlip command alternates Off ↔ On.
data ToggleVertex = Off | On
deriving stock (Eq, Show, Enum, Bounded)
-- | Aggregate has no register slots; the toggle's state is entirely
-- in the vertex.
type ToggleRegs = '[]
-- TH-derived per-constructor projections / guards / wire ctors for
-- the aggregate side. Binding suffixes pick "Flip" / "Flipped" to
-- keep call sites readable.
$( deriveAggregateCtors
''TgCmd
''ToggleRegs
[ ("TgFlip", "Flip")
]
)
$( deriveWireCtors
''TgEv
[ ("TgFlipped", "Flipped")
]
)
toggleAgg ::
SymTransducer
(HsPred ToggleRegs TgCmd)
ToggleRegs
ToggleVertex
TgCmd
TgEv
toggleAgg =
SymTransducer
{ edgesOut = toggleEdges,
initial = Off,
initialRegs = emptyRegFile,
isFinal = const True
}
toggleEdges ::
ToggleVertex ->
[Edge (HsPred ToggleRegs TgCmd) ToggleRegs TgCmd TgEv ToggleVertex]
toggleEdges = \case
Off ->
[ Edge
{ guard = isFlip,
update = UKeep,
output =
[ pack
inCtorFlip
wireFlipped
(OFCons (inpFlip #tValue) OFNil)
],
target = On
}
]
On ->
[ Edge
{ guard = isFlip,
update = UKeep,
output =
[ pack
inCtorFlip
wireFlipped
(OFCons (inpFlip #tValue) OFNil)
],
target = Off
}
]
-- * The Policy fixture (stateless) ----------------------------------------
-- | Single-vertex policy: on every TgFlipped event, emit another
-- TgFlip command with the same value. This is the "stateless
-- reactor" pattern from the orchestration design note.
data PolicyVertex = Pol
deriving stock (Eq, Show, Enum, Bounded)
type PolicyRegs = '[]
-- TH-derived guards / wire ctors for the policy side. Distinct
-- binding suffixes ("PFlipped", "PFlip") avoid clashing with the
-- aggregate-side bindings above. The underlying constructor names
-- ("TgFlipped", "TgFlip") still match across the aggregate ↔ policy
-- boundary, so 'compose''s structural-name substitution wires the
-- cascade correctly.
$( deriveAggregateCtors
''TgEv
''PolicyRegs
[ ("TgFlipped", "PFlipped")
]
)
$( deriveWireCtors
''TgCmd
[ ("TgFlip", "PFlip")
]
)
togglePolicy ::
SymTransducer
(HsPred PolicyRegs TgEv)
PolicyRegs
PolicyVertex
TgEv
TgCmd
togglePolicy =
SymTransducer
{ edgesOut = policyEdges,
initial = Pol,
initialRegs = emptyRegFile,
isFinal = const True
}
policyEdges ::
PolicyVertex ->
[Edge (HsPred PolicyRegs TgEv) PolicyRegs TgEv TgCmd PolicyVertex]
policyEdges = \case
Pol ->
[ Edge
{ guard = isPFlipped,
update = UKeep,
output =
[ pack
inCtorPFlipped
wirePFlip
(OFCons (inpPFlipped #tValue) OFNil)
],
target = Pol
}
]
-- * The composite --------------------------------------------------------
-- | The single-step feedback composite.
--
-- Vertex: @Composite ToggleVertex (Composite PolicyVertex ToggleVertex)@.
-- Read as "outer toggle, then (policy, inner toggle)". Both
-- toggles are independent copies (the implementation
-- @compose t (compose f t)@ does not share register state across
-- the two t copies — they evolve in parallel within one composite
-- step).
-- Regs: @Append '[] (Append '[] '[])@ ≡ '[].
-- Input: TgCmd
-- Output: TgEv
loop ::
SymTransducer
(HsPred (Append ToggleRegs (Append PolicyRegs ToggleRegs)) TgCmd)
(Append ToggleRegs (Append PolicyRegs ToggleRegs))
(Composite ToggleVertex (Composite PolicyVertex ToggleVertex))
TgCmd
TgEv
loop = feedback1 toggleAgg togglePolicy
-- * Test fixtures --------------------------------------------------------
externalCmd :: TgCmd
externalCmd = TgFlip (TgPayload {tValue = 42})
cascadedEvent :: TgEv
cascadedEvent = TgFlipped (TgPayload {tValue = 42})
-- * Specs ---------------------------------------------------------------
spec :: Spec
spec = do
describe "feedback1 toggleAgg togglePolicy" $ do
describe "single-step cascade" $ do
it "Composite Off (Composite Pol Off) -- TgFlip{42} --> Composite On (Composite Pol On), emitting TgFlipped{42}" $
case step loop (initial loop, initialRegs loop) externalCmd of
Just (Composite outerT (Composite policy innerT), _, [co]) -> do
outerT `shouldBe` On -- outer toggle stepped Off → On
policy `shouldBe` Pol -- policy self-loops
innerT `shouldBe` On -- inner toggle stepped Off → On (proves cascade ran)
co `shouldBe` cascadedEvent
other ->
expectationFailure
( "expected Just (Composite On (Composite Pol On), _, [TgFlipped{42}]), got "
<> showStep other
)
it "two consecutive composite steps return to the initial vertex" $
case step loop (initial loop, initialRegs loop) externalCmd of
Just (s1, regs1, _) ->
case step loop (s1, regs1) externalCmd of
Just (Composite outerT (Composite policy innerT), _, [co]) -> do
outerT `shouldBe` Off -- back to initial
policy `shouldBe` Pol
innerT `shouldBe` Off
co `shouldBe` cascadedEvent
other ->
expectationFailure
( "expected Composite Off (Composite Pol Off) after 2 steps, got "
<> showStep other
)
Nothing -> expectationFailure "first step returned Nothing"
describe "round-trip replay" $ do
it "reconstitute on [cascadedEvent] lands at Composite On (Composite Pol On)" $
case reconstitute loop [cascadedEvent] of
Just (Composite outerT (Composite policy innerT), _) -> do
outerT `shouldBe` On
policy `shouldBe` Pol
innerT `shouldBe` On
Nothing ->
expectationFailure
"reconstitute returned Nothing for the canonical one-event log"
it "reconstitute on [cascadedEvent, cascadedEvent] returns to the initial vertex" $
case reconstitute loop [cascadedEvent, cascadedEvent] of
Just (Composite outerT (Composite policy innerT), _) -> do
outerT `shouldBe` Off
policy `shouldBe` Pol
innerT `shouldBe` Off
Nothing ->
expectationFailure
"reconstitute returned Nothing for the two-event log"
describe "checkHiddenInputs" $ do
it "reports no warnings on the feedback1 composite" $
checkHiddenInputs loop `shouldBe` []
describe "isSingleValuedSym (symbolic)" $ do
it "the feedback1 composite is single-valued" $
isSingleValuedSym (withSymPred loop) `shouldBe` True
describe "omega (the wire event for one external command)" $ do
it "produces cascadedEvent on externalCmd from the initial composite state" $
omega loop (initial loop) (initialRegs loop) externalCmd
`shouldBe` [cascadedEvent]
where
showStep ::
Maybe
( Composite ToggleVertex (Composite PolicyVertex ToggleVertex),
x,
[TgEv]
) ->
String
showStep Nothing = "Nothing"
showStep (Just (cs, _, cos_)) =
"Just (" <> show cs <> ", _, " <> show cos_ <> ")"