packages feed

keiki 0.1.0.0 → 0.2.0.0

raw patch · 56 files changed

+8074/−2894 lines, 56 filesdep +QuickCheckPVP ok

version bump matches the API change (PVP)

Dependencies added: QuickCheck

API changes (from Hackage documentation)

- Keiki.Decider: Decider :: (c -> s -> [e]) -> (s -> e -> s) -> (s_streaming -> e -> Maybe s_streaming) -> s -> (s -> Bool) -> Decider c e s s_streaming
- Keiki.Decider: [decide] :: Decider c e s s_streaming -> c -> s -> [e]
- Keiki.Decider: [evolveStreaming] :: Decider c e s s_streaming -> s_streaming -> e -> Maybe s_streaming
- Keiki.Decider: [evolve] :: Decider c e s s_streaming -> s -> e -> s
- Keiki.Decider: [initialState] :: Decider c e s s_streaming -> s
- Keiki.Decider: [isTerminal] :: Decider c e s s_streaming -> s -> Bool
- Keiki.Decider: data Decider c e s s_streaming
- Keiki.Decider: toDecider :: forall phi (rs :: [Slot]) ci co s. (BoolAlg phi (RegFile rs, ci), Eq co) => SymTransducer phi rs s ci co -> Decider ci co (s, RegFile rs) (InFlight s co, RegFile rs)
- Keiki.Profunctor: [SomeSymTransducer] :: forall (rs :: [Slot]) s ci co. (WeakenR rs, KnownSlotNames rs, Bounded s, Enum s) => SymTransducer (HsPred rs ci) rs s ci co -> SomeSymTransducer ci co
- Keiki.Shape: instance (GHC.Internal.Data.Typeable.Internal.Typeable a, GHC.Internal.Data.Typeable.Internal.Typeable b) => Keiki.Shape.CanonicalTypeName (GHC.Internal.Data.Either.Either a b)
- Keiki.Shape: instance (GHC.Internal.Data.Typeable.Internal.Typeable a, GHC.Internal.Data.Typeable.Internal.Typeable b) => Keiki.Shape.CanonicalTypeName (a, b)
- Keiki.Shape: instance (GHC.Internal.Data.Typeable.Internal.Typeable a, GHC.Internal.Data.Typeable.Internal.Typeable b, GHC.Internal.Data.Typeable.Internal.Typeable c) => Keiki.Shape.CanonicalTypeName (a, b, c)
- Keiki.Shape: instance GHC.Internal.Data.Typeable.Internal.Typeable a => Keiki.Shape.CanonicalTypeName (GHC.Internal.Maybe.Maybe a)
- Keiki.Shape: instance GHC.Internal.Data.Typeable.Internal.Typeable a => Keiki.Shape.CanonicalTypeName [a]
+ Keiki.Builder: BuilderError :: v -> Int -> BuilderDefect -> BuilderError v
+ Keiki.Builder: DefectMissingGoto :: BuilderDefect
+ Keiki.Builder: DefectMissingOutputIntent :: BuilderDefect
+ Keiki.Builder: DefectMultipleGoto :: Int -> BuilderDefect
+ Keiki.Builder: DefectOutputCtorMismatch :: String -> [String] -> String -> [String] -> BuilderDefect
+ Keiki.Builder: [beDefect] :: BuilderError v -> BuilderDefect
+ Keiki.Builder: [beEdgeIndex] :: BuilderError v -> Int
+ Keiki.Builder: [beVertex] :: BuilderError v -> v
+ Keiki.Builder: buildTransducerEither :: forall (rs :: [(Symbol, Type)]) ci co v. (DistinctNames (Names rs), Eq v) => v -> RegFile rs -> (v -> Bool) -> VertexBuilder rs ci co v () -> Either (NonEmpty (BuilderError v)) (SymTransducer (HsPred rs ci) rs v ci co)
+ Keiki.Builder: data BuilderDefect
+ Keiki.Builder: data BuilderError v
+ Keiki.Builder: instance GHC.Classes.Eq Keiki.Builder.BuilderDefect
+ Keiki.Builder: instance GHC.Classes.Eq v => GHC.Classes.Eq (Keiki.Builder.BuilderError v)
+ Keiki.Builder: instance GHC.Internal.Show.Show Keiki.Builder.BuilderDefect
+ Keiki.Builder: instance GHC.Internal.Show.Show v => GHC.Internal.Show.Show (Keiki.Builder.BuilderError v)
+ Keiki.Builder: renderBuilderErrors :: Show v => NonEmpty (BuilderError v) -> String
+ Keiki.Composition: FieldArityMismatch :: EdgeRef s1 -> EdgeRef s2 -> String -> Int -> Int -> ComposeAlignmentWarning s1 s2
+ Keiki.Composition: PoisonedNameInComposition :: String -> String -> ComposeAlignmentWarning s1 s2
+ Keiki.Composition: UnconsumedWireOutput :: EdgeRef s1 -> String -> s2 -> ComposeAlignmentWarning s1 s2
+ Keiki.Composition: UnmatchedInCtorExpectation :: EdgeRef s2 -> String -> s1 -> ComposeAlignmentWarning s1 s2
+ Keiki.Composition: [WCons] :: forall (s :: Symbol) (rs1 :: [(Symbol, Type)]) t. KnownSymbol s => SlotListWitness rs1 -> SlotListWitness ('(s, t) ': rs1)
+ Keiki.Composition: [WNil] :: SlotListWitness ('[] :: [Slot])
+ Keiki.Composition: [cawAvailableFields] :: ComposeAlignmentWarning s1 s2 -> Int
+ Keiki.Composition: [cawInCtorName] :: ComposeAlignmentWarning s1 s2 -> String
+ Keiki.Composition: [cawName] :: ComposeAlignmentWarning s1 s2 -> String
+ Keiki.Composition: [cawReadPosition] :: ComposeAlignmentWarning s1 s2 -> Int
+ Keiki.Composition: [cawSharedName] :: ComposeAlignmentWarning s1 s2 -> String
+ Keiki.Composition: [cawSide] :: ComposeAlignmentWarning s1 s2 -> String
+ Keiki.Composition: [cawT1EdgeA] :: ComposeAlignmentWarning s1 s2 -> EdgeRef s1
+ Keiki.Composition: [cawT1Edge] :: ComposeAlignmentWarning s1 s2 -> EdgeRef s1
+ Keiki.Composition: [cawT1Vertex] :: ComposeAlignmentWarning s1 s2 -> s1
+ Keiki.Composition: [cawT2EdgeA] :: ComposeAlignmentWarning s1 s2 -> EdgeRef s2
+ Keiki.Composition: [cawT2Edge] :: ComposeAlignmentWarning s1 s2 -> EdgeRef s2
+ Keiki.Composition: [cawT2Vertex] :: ComposeAlignmentWarning s1 s2 -> s2
+ Keiki.Composition: [cawWireName] :: ComposeAlignmentWarning s1 s2 -> String
+ Keiki.Composition: appendWitness :: forall (rs1 :: [Slot]) (rs2 :: [Slot]). SlotListWitness rs1 -> SlotListWitness rs2 -> SlotListWitness (Append rs1 rs2)
+ Keiki.Composition: checkComposeAlignment :: forall (rs1 :: [Slot]) (rs2 :: [Slot]) s1 s2 ci1 mid co. (Bounded s1, Enum s1, Ord s1, Bounded s2, Enum s2, Ord s2) => SymTransducer (HsPred rs1 ci1) rs1 s1 ci1 mid -> SymTransducer (HsPred rs2 mid) rs2 s2 mid co -> [ComposeAlignmentWarning s1 s2]
+ Keiki.Composition: class (WeakenR rs, KnownSlotNames rs) => KnownSlots (rs :: [Slot])
+ Keiki.Composition: composeChecked :: forall (rs1 :: [Slot]) (rs2 :: [(Symbol, Type)]) s1 s2 ci1 mid co. (WeakenR rs1, Disjoint (Names rs1) (Names rs2), Bounded s1, Enum s1, Ord s1, Bounded s2, Enum s2, Ord s2) => SymTransducer (HsPred rs1 ci1) rs1 s1 ci1 mid -> SymTransducer (HsPred rs2 mid) rs2 s2 mid co -> Either [ComposeAlignmentWarning s1 s2] (SymTransducer (HsPred (Append rs1 rs2) ci1) (Append rs1 rs2) (Composite s1 s2) ci1 co)
+ Keiki.Composition: data ComposeAlignmentWarning s1 s2
+ Keiki.Composition: data SlotListWitness (rs :: [Slot])
+ Keiki.Composition: instance (GHC.Classes.Eq s2, GHC.Classes.Eq s1) => GHC.Classes.Eq (Keiki.Composition.ComposeAlignmentWarning s1 s2)
+ Keiki.Composition: instance (GHC.Classes.Ord s1, GHC.Classes.Ord s2) => GHC.Classes.Ord (Keiki.Composition.Composite s1 s2)
+ Keiki.Composition: instance (GHC.Internal.Show.Show s2, GHC.Internal.Show.Show s1) => GHC.Internal.Show.Show (Keiki.Composition.ComposeAlignmentWarning s1 s2)
+ Keiki.Composition: instance (GHC.Internal.TypeLits.KnownSymbol s, Keiki.Composition.KnownSlots rs) => Keiki.Composition.KnownSlots ('(s, t) : rs)
+ Keiki.Composition: instance Keiki.Composition.KnownSlots '[]
+ Keiki.Composition: slotWitness :: KnownSlots rs => SlotListWitness rs
+ Keiki.Composition: withDisjointNil :: forall (rs :: [Slot]) r. SlotListWitness rs -> (Disjoint (Names rs) ('[] :: [Symbol]) => r) -> r
+ Keiki.Composition: withKnownSlots :: forall (rs :: [Slot]) r. SlotListWitness rs -> (KnownSlots rs => r) -> r
+ Keiki.Composition: witnessNames :: forall (rs :: [Slot]). SlotListWitness rs -> [String]
+ Keiki.Core: HeadUnrecoverable :: EdgeRef s -> Maybe String -> [String] -> String -> TransducerValidationWarning s
+ Keiki.Core: HirEpsilonReadsInput :: HiddenInputReason
+ Keiki.Core: HirHeadUnrecoverable :: String -> [String] -> HiddenInputReason
+ Keiki.Core: HirUnionMiss :: String -> [String] -> HiddenInputReason
+ Keiki.Core: InversionAmbiguity :: s -> Int -> Int -> String -> String -> TransducerValidationWarning s
+ Keiki.Core: ReplayAmbiguousInversions :: s -> [MatchedEdgeSummary s] -> ReplayStepFailure s co
+ Keiki.Core: ReplayEventFailed :: ReplayStepFailure s co -> ReplayFailureReason s co
+ Keiki.Core: ReplayFailure :: Int -> InFlight s co -> ReplayFailureReason s co -> ReplayFailure s co
+ Keiki.Core: ReplayLogTruncated :: [co] -> ReplayFailureReason s co
+ Keiki.Core: ReplayNoInvertingEdge :: s -> [RejectedEdgeSummary s] -> ReplayStepFailure s co
+ Keiki.Core: ReplayQueueMismatch :: s -> co -> [co] -> ReplayStepFailure s co
+ Keiki.Core: StateChangingEpsilon :: EdgeRef s -> Bool -> Bool -> String -> TransducerValidationWarning s
+ Keiki.Core: UnguardedInputRead :: EdgeRef s -> Maybe String -> String -> TransducerValidationWarning s
+ Keiki.Core: [PLeftArm] :: forall (rs :: [Slot]) ci1 ci2. HsPred rs (Either ci1 ci2)
+ Keiki.Core: [PRightArm] :: forall (rs :: [Slot]) ci1 ci2. HsPred rs (Either ci1 ci2)
+ Keiki.Core: [checkGuardImpliesInputRead] :: ValidationOptions -> Bool
+ Keiki.Core: [checkHeadRecoverability] :: ValidationOptions -> Bool
+ Keiki.Core: [checkInversionAmbiguity] :: ValidationOptions -> Bool
+ Keiki.Core: [checkStateChangingEpsilon] :: ValidationOptions -> Bool
+ Keiki.Core: [replayFailedIndex] :: ReplayFailure s co -> Int
+ Keiki.Core: [replayFailedState] :: ReplayFailure s co -> InFlight s co
+ Keiki.Core: [replayFailureReason] :: ReplayFailure s co -> ReplayFailureReason s co
+ Keiki.Core: [tvwChangesVertex] :: TransducerValidationWarning s -> Bool
+ Keiki.Core: [tvwTailOnlySlots] :: TransducerValidationWarning s -> [String]
+ Keiki.Core: [tvwWireCtor] :: TransducerValidationWarning s -> String
+ Keiki.Core: [tvwWritesRegisters] :: TransducerValidationWarning s -> Bool
+ Keiki.Core: applyEventStreamingEither :: forall phi (rs :: [Slot]) ci co s. (BoolAlg phi (RegFile rs, ci), Eq co) => SymTransducer phi rs s ci co -> InFlight s co -> RegFile rs -> co -> Either (ReplayStepFailure s co) (InFlight s co, RegFile rs)
+ Keiki.Core: applyEventsEither :: forall phi (rs :: [Slot]) ci co s. (BoolAlg phi (RegFile rs, ci), Eq co) => SymTransducer phi rs s ci co -> (s, RegFile rs) -> [co] -> Either (ReplayFailure s co) (s, RegFile rs)
+ Keiki.Core: data HiddenInputReason
+ Keiki.Core: data ReplayFailure s co
+ Keiki.Core: data ReplayFailureReason s co
+ Keiki.Core: data ReplayStepFailure s co
+ Keiki.Core: guardImpliesInputReadWarnings :: forall (rs :: [Slot]) s ci co. (Bounded s, Enum s, Show s) => SymTransducer (HsPred rs ci) rs s ci co -> [TransducerValidationWarning s]
+ Keiki.Core: headRecoverabilityWarnings :: forall s phi (rs :: [Slot]) ci co. (Bounded s, Enum s) => SymTransducer phi rs s ci co -> [TransducerValidationWarning s]
+ Keiki.Core: hiddenInputReasons :: forall phi (rs :: [Slot]) ci co s. Edge phi rs ci co s -> [HiddenInputReason]
+ Keiki.Core: instance (GHC.Classes.Eq s, GHC.Classes.Eq co) => GHC.Classes.Eq (Keiki.Core.ReplayFailure s co)
+ Keiki.Core: instance (GHC.Classes.Eq s, GHC.Classes.Eq co) => GHC.Classes.Eq (Keiki.Core.ReplayFailureReason s co)
+ Keiki.Core: instance (GHC.Classes.Eq s, GHC.Classes.Eq co) => GHC.Classes.Eq (Keiki.Core.ReplayStepFailure s co)
+ Keiki.Core: instance (GHC.Internal.Show.Show s, GHC.Internal.Show.Show co) => GHC.Internal.Show.Show (Keiki.Core.ReplayFailure s co)
+ Keiki.Core: instance (GHC.Internal.Show.Show s, GHC.Internal.Show.Show co) => GHC.Internal.Show.Show (Keiki.Core.ReplayFailureReason s co)
+ Keiki.Core: instance (GHC.Internal.Show.Show s, GHC.Internal.Show.Show co) => GHC.Internal.Show.Show (Keiki.Core.ReplayStepFailure s co)
+ Keiki.Core: instance GHC.Classes.Eq Keiki.Core.PureRelation
+ Keiki.Core: instance GHC.Classes.Eq Keiki.Core.PureVariable
+ Keiki.Core: instance GHC.Internal.Show.Show Keiki.Core.PureRelation
+ Keiki.Core: instance GHC.Internal.Show.Show Keiki.Core.PureVariable
+ Keiki.Core: inversionAmbiguityWarnings :: forall (rs :: [Slot]) s ci co. (Bounded s, Enum s, Show s) => SymTransducer (HsPred rs ci) rs s ci co -> [TransducerValidationWarning s]
+ Keiki.Core: reconstituteEither :: forall phi (rs :: [Slot]) ci co s. (BoolAlg phi (RegFile rs, ci), Eq co) => SymTransducer phi rs s ci co -> [co] -> Either (ReplayFailure s co) (s, RegFile rs)
+ Keiki.Core: replayEvents :: forall phi (rs :: [Slot]) ci co s. (BoolAlg phi (RegFile rs, ci), Eq co) => SymTransducer phi rs s ci co -> (InFlight s co, RegFile rs) -> [co] -> Either (ReplayFailure s co) (InFlight s co, RegFile rs)
+ Keiki.Core: slotNamesOf :: forall ci (ifs :: [Slot]). InCtor ci ifs -> [String]
+ Keiki.Core: stateChangingEpsilonWarnings :: forall (rs :: [Slot]) s ci co. (Bounded s, Enum s, Eq s, Show s) => SymTransducer (HsPred rs ci) rs s ci co -> [TransducerValidationWarning s]
+ Keiki.Profunctor: PoisonProvenance :: !Bool -> !Bool -> PoisonProvenance
+ Keiki.Profunctor: PoisonedCompositionError :: String -> String -> PoisonedCompositionError
+ Keiki.Profunctor: [pceDetail] :: PoisonedCompositionError -> String
+ Keiki.Profunctor: [pceSide] :: PoisonedCompositionError -> String
+ Keiki.Profunctor: [poisonedInput] :: PoisonProvenance -> !Bool
+ Keiki.Profunctor: [poisonedOutput] :: PoisonProvenance -> !Bool
+ Keiki.Profunctor: data PoisonProvenance
+ Keiki.Profunctor: data PoisonedCompositionError
+ Keiki.Profunctor: instance GHC.Classes.Eq Keiki.Profunctor.PoisonProvenance
+ Keiki.Profunctor: instance GHC.Classes.Eq Keiki.Profunctor.PoisonedCompositionError
+ Keiki.Profunctor: instance GHC.Internal.Exception.Type.Exception Keiki.Profunctor.PoisonedCompositionError
+ Keiki.Profunctor: instance GHC.Internal.Show.Show Keiki.Profunctor.PoisonProvenance
+ Keiki.Profunctor: instance GHC.Internal.Show.Show Keiki.Profunctor.PoisonedCompositionError
+ Keiki.Profunctor: pattern SomeSymTransducer :: forall ci co (rs :: [Slot]) s. () => (KnownSlots rs, Bounded s, Enum s) => SymTransducer (HsPred rs ci) rs s ci co -> SomeSymTransducer ci co
+ Keiki.Shape: instance (Keiki.Shape.CanonicalTypeName a, Keiki.Shape.CanonicalTypeName b) => Keiki.Shape.CanonicalTypeName (GHC.Internal.Data.Either.Either a b)
+ Keiki.Shape: instance (Keiki.Shape.CanonicalTypeName a, Keiki.Shape.CanonicalTypeName b) => Keiki.Shape.CanonicalTypeName (a, b)
+ Keiki.Shape: instance (Keiki.Shape.CanonicalTypeName a, Keiki.Shape.CanonicalTypeName b, Keiki.Shape.CanonicalTypeName c) => Keiki.Shape.CanonicalTypeName (a, b, c)
+ Keiki.Shape: instance Keiki.Shape.CanonicalTypeName a => Keiki.Shape.CanonicalTypeName (GHC.Internal.Maybe.Maybe a)
+ Keiki.Shape: instance Keiki.Shape.CanonicalTypeName a => Keiki.Shape.CanonicalTypeName [a]
+ Keiki.Symbolic: [seInputArm] :: SymEnv -> SBool
+ Keiki.Symbolic: satResultIsProvablyUnsat :: SatResult -> Bool
- Keiki.Acceptor: outputAcceptor :: forall phi (rs :: [Slot]) ci co s. (BoolAlg phi (RegFile rs, ci), Eq co) => SymTransducer phi rs s ci co -> Acceptor co (s, RegFile rs)
+ Keiki.Acceptor: outputAcceptor :: forall phi (rs :: [Slot]) ci co s. (BoolAlg phi (RegFile rs, ci), Eq co) => SymTransducer phi rs s ci co -> Acceptor co (InFlight s co, RegFile rs)
- Keiki.Builder: (.=) :: forall (name :: Symbol) r (rs :: [(Symbol, Type)]) ci (ifs :: [Slot]) co v (w :: [Symbol]). (KnownSymbol name, Disjoint '[name] w) => IndexN name rs r -> Term rs ci ifs r -> EdgeBuilder rs ci co v w (Concat '[name] w) ()
+ Keiki.Builder: (.=) :: forall (name :: Symbol) r (rs :: [(Symbol, Type)]) ci (ifs :: [Slot]) co v (pin :: Maybe [Slot]) (w :: [Symbol]). (KnownSymbol name, Disjoint '[name] w) => IndexN name rs r -> Term rs ci ifs r -> EdgeBuilder rs ci co v pin w (Concat '[name] w) ()
- Keiki.Builder: (=:) :: forall (name :: Symbol) r (rs :: [(Symbol, Type)]) ci (ifs :: [Slot]) co v (w :: [Symbol]). (KnownSymbol name, Disjoint '[name] w) => IndexN name rs r -> Term rs ci ifs r -> EdgeBuilder rs ci co v w (Concat '[name] w) ()
+ Keiki.Builder: (=:) :: forall (name :: Symbol) r (rs :: [(Symbol, Type)]) ci (ifs :: [Slot]) co v (pin :: Maybe [Slot]) (w :: [Symbol]). (KnownSymbol name, Disjoint '[name] w) => IndexN name rs r -> Term rs ci ifs r -> EdgeBuilder rs ci co v pin w (Concat '[name] w) ()
- Keiki.Builder: (>>) :: forall (rs :: [Slot]) ci co v (w1 :: [Symbol]) (w2 :: [Symbol]) a (w3 :: [Symbol]) b. EdgeBuilder rs ci co v w1 w2 a -> EdgeBuilder rs ci co v w2 w3 b -> EdgeBuilder rs ci co v w1 w3 b
+ Keiki.Builder: (>>) :: forall (rs :: [Slot]) ci co v (pin :: Maybe [Slot]) (w1 :: [Symbol]) (w2 :: [Symbol]) a (w3 :: [Symbol]) b. EdgeBuilder rs ci co v pin w1 w2 a -> EdgeBuilder rs ci co v pin w2 w3 b -> EdgeBuilder rs ci co v pin w1 w3 b
- Keiki.Builder: (>>=) :: forall (rs :: [Slot]) ci co v (w1 :: [Symbol]) (w2 :: [Symbol]) a (w3 :: [Symbol]) b. EdgeBuilder rs ci co v w1 w2 a -> (a -> EdgeBuilder rs ci co v w2 w3 b) -> EdgeBuilder rs ci co v w1 w3 b
+ Keiki.Builder: (>>=) :: forall (rs :: [Slot]) ci co v (pin :: Maybe [Slot]) (w1 :: [Symbol]) (w2 :: [Symbol]) a (w3 :: [Symbol]) b. EdgeBuilder rs ci co v pin w1 w2 a -> (a -> EdgeBuilder rs ci co v pin w2 w3 b) -> EdgeBuilder rs ci co v pin w1 w3 b
- Keiki.Builder: buildTransducer :: forall (rs :: [Slot]) ci co v. (Bounded v, Enum v, Eq v, Show v) => v -> RegFile rs -> (v -> Bool) -> VertexBuilder rs ci co v () -> SymTransducer (HsPred rs ci) rs v ci co
+ Keiki.Builder: buildTransducer :: forall (rs :: [(Symbol, Type)]) ci co v. (DistinctNames (Names rs), Eq v, Show v) => v -> RegFile rs -> (v -> Bool) -> VertexBuilder rs ci co v () -> SymTransducer (HsPred rs ci) rs v ci co
- Keiki.Builder: data EdgeBuilder (rs :: [Slot]) ci co v (w :: [Symbol]) (w' :: [Symbol]) a
+ Keiki.Builder: data EdgeBuilder (rs :: [Slot]) ci co v (pin :: Maybe [Slot]) (w :: [Symbol]) (w' :: [Symbol]) a
- Keiki.Builder: emit :: forall co fs (rs :: [Slot]) ci (ifs :: [Slot]) v (w :: [Symbol]) rec. ToOutFields rec rs ci ifs fs => WireCtor co fs -> rec -> EdgeBuilder rs ci co v w w ()
+ Keiki.Builder: emit :: forall co fs (rs :: [Slot]) ci (ifs :: [Slot]) v (w :: [Symbol]) rec. ToOutFields rec rs ci ifs fs => WireCtor co fs -> rec -> EdgeBuilder rs ci co v ('Just ifs) w w ()
- Keiki.Builder: emitWith :: forall co fs (rs :: [Slot]) ci v (w :: [Symbol]) (ifs :: [Slot]) rec. ToOutFields rec rs ci ifs fs => InCtor ci ifs -> WireCtor co fs -> rec -> EdgeBuilder rs ci co v w w ()
+ Keiki.Builder: emitWith :: forall co fs (rs :: [Slot]) ci v (pin :: Maybe [Slot]) (w :: [Symbol]) (ifs :: [Slot]) rec. ToOutFields rec rs ci ifs fs => InCtor ci ifs -> WireCtor co fs -> rec -> EdgeBuilder rs ci co v pin w w ()
- Keiki.Builder: from :: forall v (rs :: [Slot]) ci co. (Eq v, Show v) => v -> EdgeListBuilder rs ci co v () -> VertexBuilder rs ci co v ()
+ Keiki.Builder: from :: forall v (rs :: [Slot]) ci co. v -> EdgeListBuilder rs ci co v () -> VertexBuilder rs ci co v ()
- Keiki.Builder: goto :: forall v (rs :: [Slot]) ci co (w :: [Symbol]). v -> EdgeBuilder rs ci co v w w ()
+ Keiki.Builder: goto :: forall v (rs :: [Slot]) ci co (pin :: Maybe [Slot]) (w :: [Symbol]). v -> EdgeBuilder rs ci co v pin w w ()
- Keiki.Builder: noEmit :: forall (rs :: [Slot]) ci co v (w :: [Symbol]). EdgeBuilder rs ci co v w w ()
+ Keiki.Builder: noEmit :: forall (rs :: [Slot]) ci co v (pin :: Maybe [Slot]) (w :: [Symbol]). EdgeBuilder rs ci co v pin w w ()
- Keiki.Builder: onCmd :: forall ci (ifs :: [Slot]) (rs :: [Slot]) co v (w :: [Symbol]). Show v => InCtor ci ifs -> (PayloadProj rs ci ifs -> EdgeBuilder rs ci co v ('[] :: [Symbol]) w ()) -> EdgeListBuilder rs ci co v ()
+ Keiki.Builder: onCmd :: forall ci (ifs :: [Slot]) (rs :: [Slot]) co v (w :: [Symbol]). InCtor ci ifs -> (PayloadProj rs ci ifs -> EdgeBuilder rs ci co v ('Just ifs) ('[] :: [Symbol]) w ()) -> EdgeListBuilder rs ci co v ()
- Keiki.Builder: onEpsilon :: forall (rs :: [Slot]) ci co v (w :: [Symbol]). Show v => EdgeBuilder rs ci co v ('[] :: [Symbol]) w () -> EdgeListBuilder rs ci co v ()
+ Keiki.Builder: onEpsilon :: forall (rs :: [Slot]) ci co v (w :: [Symbol]). EdgeBuilder rs ci co v ('Nothing :: Maybe [Slot]) ('[] :: [Symbol]) w () -> EdgeListBuilder rs ci co v ()
- Keiki.Builder: pure :: forall a (rs :: [Slot]) ci co v (w :: [Symbol]). a -> EdgeBuilder rs ci co v w w a
+ Keiki.Builder: pure :: forall a (rs :: [Slot]) ci co v (pin :: Maybe [Slot]) (w :: [Symbol]). a -> EdgeBuilder rs ci co v pin w w a
- Keiki.Builder: requireCmp :: forall r (rs :: [Slot]) ci (ifs1 :: [Slot]) (ifs2 :: [Slot]) co v (w :: [Symbol]). (Ord r, Typeable r) => Cmp -> Term rs ci ifs1 r -> Term rs ci ifs2 r -> EdgeBuilder rs ci co v w w ()
+ Keiki.Builder: requireCmp :: forall r (rs :: [Slot]) ci (ifs1 :: [Slot]) (ifs2 :: [Slot]) co v (pin :: Maybe [Slot]) (w :: [Symbol]). (Ord r, Typeable r) => Cmp -> Term rs ci ifs1 r -> Term rs ci ifs2 r -> EdgeBuilder rs ci co v pin w w ()
- Keiki.Builder: requireEq :: forall r (rs :: [Slot]) ci (ifs1 :: [Slot]) (ifs2 :: [Slot]) co v (w :: [Symbol]). (Eq r, Typeable r) => Term rs ci ifs1 r -> Term rs ci ifs2 r -> EdgeBuilder rs ci co v w w ()
+ Keiki.Builder: requireEq :: forall r (rs :: [Slot]) ci (ifs1 :: [Slot]) (ifs2 :: [Slot]) co v (pin :: Maybe [Slot]) (w :: [Symbol]). (Eq r, Typeable r) => Term rs ci ifs1 r -> Term rs ci ifs2 r -> EdgeBuilder rs ci co v pin w w ()
- Keiki.Builder: requireGe :: forall r (rs :: [Slot]) ci (ifs1 :: [Slot]) (ifs2 :: [Slot]) co v (w :: [Symbol]). (Ord r, Typeable r) => Term rs ci ifs1 r -> Term rs ci ifs2 r -> EdgeBuilder rs ci co v w w ()
+ Keiki.Builder: requireGe :: forall r (rs :: [Slot]) ci (ifs1 :: [Slot]) (ifs2 :: [Slot]) co v (pin :: Maybe [Slot]) (w :: [Symbol]). (Ord r, Typeable r) => Term rs ci ifs1 r -> Term rs ci ifs2 r -> EdgeBuilder rs ci co v pin w w ()
- Keiki.Builder: requireGt :: forall r (rs :: [Slot]) ci (ifs1 :: [Slot]) (ifs2 :: [Slot]) co v (w :: [Symbol]). (Ord r, Typeable r) => Term rs ci ifs1 r -> Term rs ci ifs2 r -> EdgeBuilder rs ci co v w w ()
+ Keiki.Builder: requireGt :: forall r (rs :: [Slot]) ci (ifs1 :: [Slot]) (ifs2 :: [Slot]) co v (pin :: Maybe [Slot]) (w :: [Symbol]). (Ord r, Typeable r) => Term rs ci ifs1 r -> Term rs ci ifs2 r -> EdgeBuilder rs ci co v pin w w ()
- Keiki.Builder: requireGuard :: forall (rs :: [Slot]) ci co v (w :: [Symbol]). HsPred rs ci -> EdgeBuilder rs ci co v w w ()
+ Keiki.Builder: requireGuard :: forall (rs :: [Slot]) ci co v (pin :: Maybe [Slot]) (w :: [Symbol]). HsPred rs ci -> EdgeBuilder rs ci co v pin w w ()
- Keiki.Builder: requireLe :: forall r (rs :: [Slot]) ci (ifs1 :: [Slot]) (ifs2 :: [Slot]) co v (w :: [Symbol]). (Ord r, Typeable r) => Term rs ci ifs1 r -> Term rs ci ifs2 r -> EdgeBuilder rs ci co v w w ()
+ Keiki.Builder: requireLe :: forall r (rs :: [Slot]) ci (ifs1 :: [Slot]) (ifs2 :: [Slot]) co v (pin :: Maybe [Slot]) (w :: [Symbol]). (Ord r, Typeable r) => Term rs ci ifs1 r -> Term rs ci ifs2 r -> EdgeBuilder rs ci co v pin w w ()
- Keiki.Builder: requireLt :: forall r (rs :: [Slot]) ci (ifs1 :: [Slot]) (ifs2 :: [Slot]) co v (w :: [Symbol]). (Ord r, Typeable r) => Term rs ci ifs1 r -> Term rs ci ifs2 r -> EdgeBuilder rs ci co v w w ()
+ Keiki.Builder: requireLt :: forall r (rs :: [Slot]) ci (ifs1 :: [Slot]) (ifs2 :: [Slot]) co v (pin :: Maybe [Slot]) (w :: [Symbol]). (Ord r, Typeable r) => Term rs ci ifs1 r -> Term rs ci ifs2 r -> EdgeBuilder rs ci co v pin w w ()
- Keiki.Builder: return :: forall a (rs :: [Slot]) ci co v (w :: [Symbol]). a -> EdgeBuilder rs ci co v w w a
+ Keiki.Builder: return :: forall a (rs :: [Slot]) ci co v (pin :: Maybe [Slot]) (w :: [Symbol]). a -> EdgeBuilder rs ci co v pin w w a
- Keiki.Core: ValidationOptions :: Bool -> Bool -> Bool -> Bool -> ValidationOptions
+ Keiki.Core: ValidationOptions :: Bool -> Bool -> Bool -> Bool -> Bool -> Bool -> Bool -> Bool -> ValidationOptions
- Keiki.Profunctor: someSymTransducer :: forall (rs :: [Slot]) s ci co. (WeakenR rs, KnownSlotNames rs, Bounded s, Enum s) => SymTransducer (HsPred rs ci) rs s ci co -> SomeSymTransducer ci co
+ Keiki.Profunctor: someSymTransducer :: forall (rs :: [Slot]) s ci co. (KnownSlots rs, Bounded s, Enum s) => SymTransducer (HsPred rs ci) rs s ci co -> SomeSymTransducer ci co
- Keiki.Symbolic: SymEnv :: SBV String -> IORef (Map String SomeSBV) -> SymEnv
+ Keiki.Symbolic: SymEnv :: SBV String -> SBool -> IORef (Map String SomeSBV) -> SymEnv
- Keiki.Symbolic: checkDeadEdgesSym :: forall s (rs :: [Slot]) ci co. (Bounded s, Enum s, Show s) => SymTransducer (HsPred rs ci) rs s ci co -> [DeadEdgeWarning s]
+ Keiki.Symbolic: checkDeadEdgesSym :: forall s (rs :: [Slot]) ci co. (Bounded s, Enum s) => SymTransducer (HsPred rs ci) rs s ci co -> [DeadEdgeWarning s]

Files

CHANGELOG.md view
@@ -9,6 +9,153 @@ ## [Unreleased]  +## [0.2.0.0] — 2026-07-13++### Added++- `Keiki.Symbolic.satResultIsProvablyUnsat` exposes the conservative solver+  verdict used by symbolic emptiness checks: only a definite `Unsatisfiable`+  result proves a predicate empty.+- `Keiki.Composition.checkComposeAlignment` and `composeChecked` report+  constructor-name drift, unmatched expectations, field-arity mismatches, and+  mapped/poisoned boundary names with exact source edge locations.+- `PLeftArm` and `PRightArm` give `alternative` concrete and symbolic+  `Either`-arm exclusion even when an underlying edge guard is `PTop`.++- `Keiki.Builder.buildTransducerEither` returns all eagerly located builder+  defects as structured `BuilderError` values. `BuilderDefect` and+  `renderBuilderErrors` expose the same validation and historical message format+  without exception plumbing.+- `DistinctNames` provides the canonical compile-time duplicate register-slot+  check, and `slotNamesOf` is now exported from `Keiki.Core` for structural+  constructor validation.+- Structured replay diagnostics are available through+  `applyEventStreamingEither`, `replayEvents`, `applyEventsEither`, and+  `reconstituteEither`. `ReplayStepFailure`, `ReplayFailureReason`, and+  `ReplayFailure` identify the failing event index, wrapper state, and exact+  reason, including ambiguous inversion, queue mismatch, and truncated+  multi-event chains. The existing `Maybe` functions remain compatibility+  wrappers over this primary surface.++### Changed++- Aggregate-constructor TH now rejects positional payload types immediately with+  a record-syntax diagnostic. The `*All` and `*With` enumeration splices warn+  when they skip unsupported GADT or explicitly quantified constructors.+- **Breaking:** built-in `CanonicalTypeName` instances now use pinned,+  module-independent names such as `Int`, `Text`, and `Maybe(Int)`. Every+  non-empty register-file shape hash therefore changes once in this release.+  Snapshot stores keyed by the old hash treat existing snapshots as cache misses+  and replay the event log in full; keiro follows this benign fallback path.+- `Maybe`, list, `Either`, and tuple canonical-name instances now recurse through+  `CanonicalTypeName` instead of `Typeable`, so application overrides compose+  inside containers. A custom type used there may need+  `deriving anyclass (CanonicalTypeName)`; missing evidence is a compile-time+  migration rather than silent hash drift.+- Symbolic emptiness checks no longer mistake solver uncertainty for proof of+  unsatisfiability. `symIsBot`, `symSatExt`, `isSingleValuedSym`,+  `withSymPred`, `checkTransitionDeterminismSym`, and `checkDeadEdgesSym` keep+  their existing names and signatures; `Unknown`, `ProofError`, and other+  non-definitive solver results now fail conservatively instead of blessing a+  guard pair as disjoint or an edge as dead. These pure-looking APIs still run+  z3 through `unsafePerformIO` and throw if the solver is unavailable.+- `checkDeadEdgesSym` no longer carries a redundant `Show s` constraint. Relaxing+  a constraint is source-compatible, so existing call sites need no change.+- Symbolic encodings for `Word8`, `Word16`, `Word32`, `Word64`, `Int32`, and+  `Int64` now use exact fixed-width SBV values, preserving modular wraparound.+  `UTCTime` now round-trips at its native picosecond resolution instead of+  truncating to whole seconds. Platform-sized `Int` remains modeled as an+  unbounded `Integer`, so analyses whose truth depends on `Int` overflow should+  use an explicitly sized type.+- The pure determinism pass used by `validateTransducer` now proves overlap+  through supported conjunction spines, including constructor consistency,+  exact integral intervals, and concrete literal witnesses. Unsupported+  disjunctions, negations, arithmetic, opaque terms, and variable-to-variable+  comparisons remain unknown and produce no pure warning; use the z3-backed+  checks as the exact gate. Existing consumers, including keiro, remain+  source-compatible, but may see new `NondeterministicPair` warnings. Such+  warnings are true positives and should be repaired or explicitly+  acknowledged rather than suppressed by pinning the old behavior.+- `SomeSymTransducer` now carries input/output poison provenance while retaining+  its one-argument compatibility pattern. Variance rewrites stamp constructor+  names with `#lmapped`/`#rmapped`; categorical composition across a poisoned+  boundary raises `PoisonedCompositionError` instead of silently bypassing a+  map or producing a dead pipeline.+- `feedback1` is documented as its actual two-copy cascade contract, not+  shared-state aggregate feedback; no `feedback1Checked` API is exposed.++- `runUpdate` now gives `UCombine` snapshot (parallel-assignment)+  semantics: every right-hand side reads the edge-entry register file and+  writes apply left-to-right. Sequential `compose` now symbolically threads+  t2 register writes across multi-event chains, so stateful composition+  agrees with stepping t1 and then t2 event-by-event. Constructor-mismatched+  comparison leaves become `PBot`, while mismatches in other positions use+  walker-safe opaque poison terms. This is a pre-release behavior change;+  the surveyed current keiro consumer has no update depending on the former+  threaded-within-one-edge behavior and does not call composition operators.+- `validateTransducer defaultValidationOptions` now enforces four additional+  replay-safety checks: head-event recoverability, cross-edge inversion+  ambiguity, constructor guards before input-field reads, and state-changing+  ε-edges. The corresponding warning constructors are `HeadUnrecoverable`,+  `InversionAmbiguity`, `UnguardedInputRead`, and `StateChangingEpsilon`; the+  new default-on option fields are `checkHeadRecoverability`,+  `checkInversionAmbiguity`, `checkGuardImpliesInputRead`, and+  `checkStateChangingEpsilon`. Code that exhaustively matches+  `TransducerValidationWarning` must add these four cases, and code that+  constructs `ValidationOptions` should record-update `defaultValidationOptions`+  so future checks remain enabled.+- `checkHiddenInputs` now requires the first event of a multi-event edge to+  recover every consumed command field. Coverage spread across the union of the+  head and tail is no longer accepted because streaming replay inverts only the+  head; tail-only fields produce `HeadUnrecoverable` through+  `validateTransducer` and an equivalent legacy string warning through+  `checkHiddenInputs`.+- The canonical User Registration pre-confirmation deletion now emits+  `AccountDeleted` instead of changing vertex and registers silently, so its+  forward result is recoverable from its persisted log.+- `Keiki.Builder` now requires every `onCmd`/`onEpsilon` edge body to+  declare its output intent explicitly. A body that reaches `goto` without+  calling `emit`/`emitWith` or `noEmit` is an eager construction error instead+  of silently becoming an ε-edge; deliberately silent edges keep working by+  calling `noEmit`.+- `Keiki.Builder` now validates every declared edge when the returned transducer+  is evaluated to weak head normal form. Missing/multiple `goto` calls and+  mismatched explicit `emitWith` constructors no longer remain latent until an+  affected `edgesOut` branch is demanded; duplicate `from` blocks merge in+  declaration order with stable per-vertex edge indices.+- The builder pins the enclosing `onCmd` input schema in `EdgeBuilder`. Passing+  another command's term-fields record to `emit`, or calling `emit` inside+  `onEpsilon`, is now a compile-time error. `emitWith` remains the explicit form+  for `onEpsilon` and must agree with the enclosing constructor inside `onCmd`.+- `buildTransducer` and `buildTransducerEither` require+  `DistinctNames (Names rs)`, rejecting register files with duplicated slot+  names instead of silently resolving the first occurrence. Vertex grouping now+  requires `Eq v`; the unused `Bounded v` and `Enum v` constraints were removed.+- Current keiro authoring remains source-compatible: the standard+  `B.emit wireCtorX XTermFields {..}` shape and both build call forms are+  unchanged for valid aggregates.+- `Keiki.Profunctor` no longer fabricates method-carrying `WeakenR` and+  `KnownSlotNames` dictionaries with `unsafeCoerce`. Nested stateful Category+  composition previously misindexed register reads and writes and hid slot names+  from `CategoryOverlapError`. `SomeSymTransducer` now carries the exported+  `KnownSlots`/`SlotListWitness` evidence from `Keiki.Composition`, and composite+  evidence is derived by structural induction. The smart constructor's structural+  constraints are now expressed as `KnownSlots rs`.+- `Keiki.Acceptor.outputAcceptor` now carries+  `(InFlight s co, RegFile rs)` and steps with `applyEventStreaming`, so its+  acceptance result agrees with `reconstitute` for multi-event and truncated+  logs as well as letter-only logs.++### Removed++- `Keiki.Builder` no longer uses `unsafeCoerce` to reinterpret the input schema+  recovered by `emit`; the schema relationship is represented in its types.+- The lossy pre-release Decider facade has been removed. Use `stepEither` for+  forward decisions and the structured `Keiki.Core` replay functions for+  hydration; there is no letter-only replay facade that silently retains the+  input state after a failure.++ ## [0.1.0.0] — 2026-06-07  Initial Hackage release. Public surface stabilised around the@@ -32,13 +179,6 @@ - `Keiki.Builder` — the monadic edge-authoring DSL. - `Keiki.Composition` — sequential, alternative, and single-step   feedback combinators on `SymTransducer`s.-- `Keiki.Decider` — the Chassaing-shape `Decider` facade-  (`decide` / `evolve` / `evolveStreaming` / `initialState` /-  `isTerminal`) derived mechanically from a `SymTransducer`.-  `decide` returns the full event list directly, including-  length-2+ chains from multi-event edges; `evolveStreaming`-  threads the `Keiki.Core.InFlight` wrapper through length-N-  edges for event-by-event streaming replay. - `Keiki.Generics` — `RegFieldsOf`, `GRecord`, `mkInCtor` /   `mkInCtorVia`, `mkWireCtor` / `mkWireCtorVia`, plus `EmptyRegFile`. - `Keiki.Generics.TH` — `deriveAggregateCtors`, `deriveWireCtors`,
README.md view
@@ -5,10 +5,10 @@ symbolic-register finite-state transducer.  keiki keeps the runtime boundary deliberately small. You describe an-aggregate or workflow once as a typed transducer; the library derives-the event-sourcing facade, replay, acceptors, projections, composition-machinery, diagram renderers, and optional symbolic checks from that-single declaration.+aggregate or workflow once as a typed transducer; the library provides+forward decisions, replay, acceptors, projections, composition machinery,+diagram renderers, and optional symbolic checks from that single+declaration.  ## The name @@ -44,15 +44,17 @@ keiki models all three as **finite-state transducers** with a typed register file and predicate-labelled guards: a practical hybrid of Symbolic Finite Transducers and Streaming String Transducers. From one-`SymTransducer` declaration, the library mechanically derives:+`SymTransducer` declaration, the library provides: -- the Chassaing-shape `Decider` (`decide`/`evolve`/`initialState`/`isTerminal`),+- strict forward-decision and replay operations with structured failure diagnostics+  (`stepEither`, `reconstituteEither`, and `replayEvents`), - input- and output-side `Acceptor`s, - per-vertex projections (the "B-presentation" view),-- composition (`compose`, `alternative`, `feedback1`),-- profunctor / `Category` / `Strong` / `Choice` instances,+- checked sequential composition (`composeChecked`) plus `alternative` and `feedback1`,+- profunctor / `Category` / `Strong` / `Choice` / `Arrow` instances, - Mermaid and Markdown renderers for documentation,-- single-valuedness checks via SBV + z3 as an opt-in symbolic CI gate.+- eager builder validation, default-on replay-safety checks, and optional+  single-valuedness checks via SBV + z3.  `delta` / `omega` / `applyEvent` use concrete predicate evaluation — no solver in the per-event hot path. Solver dispatch is reserved for@@ -67,7 +69,9 @@ The core package is intentionally codec-free. JSON support lives in [`keiki-codec-json`](keiki-codec-json/README.md), and downstream codec testing helpers live in-[`keiki-codec-json-test`](keiki-codec-json-test/README.md).+[`keiki-codec-json-test`](keiki-codec-json-test/README.md). The JSON event+codec supports pinned wire kinds, in-band schema versions, and explicit+upcaster chains for persisted-event evolution.  ## Build @@ -106,9 +110,9 @@       B.goto EmailSentVertex ``` -`Decider`, `Acceptor`, replay, and the per-vertex view are all derived-from this one declaration. See the sibling `jitsurei/` package for the-full worked aggregates the test suite drives.+Forward decisions, structured replay, `Acceptor`s, and the per-vertex+view all operate from this one declaration. See the downstream+`jitsurei` package for the full worked aggregates the test suite drives.  ## Repository documentation 
keiki.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.0 name:            keiki-version:         0.1.0.0+version:         0.2.0.0 synopsis:        Pure core for symbolic-register transducer event sourcing. description:   A Haskell library for the pure core of event sourcing, workflow@@ -9,11 +9,12 @@   Transducers and Streaming String Transducers).   .   Provides a typed register-file (@RegFile rs@), a deterministic-  transducer DSL with predicate-labelled guards, composition-  combinators (sequential, alternative, single-step feedback), a-  mechanically-derived @Decider@ facade, input- and output-side-  acceptors, generic Mermaid renderers, and a GHC-upgrade-safe-  shape hash (@Keiki.Shape@) for snapshot discrimination.+  transducer DSL with eager builder validation and replay-safety+  analyses, structured forward-decision and replay operations,+  composition combinators (checked sequential, alternative,+  single-step feedback), input- and output-side acceptors, generic+  Mermaid renderers, and a GHC-upgrade-safe shape hash+  (@Keiki.Shape@) for snapshot discrimination.   .   The library is intentionally codec-free: the pure layer talks   only typed Haskell values. JSON serialisation lives in the@@ -31,6 +32,10 @@   CHANGELOG.md   README.md +source-repository head+  type:     git+  location: https://github.com/shinzui/keiki.git+ common warnings   ghc-options:     -Wall -Wcompat -Widentities -Wincomplete-record-updates@@ -61,7 +66,6 @@     Keiki.Builder     Keiki.Composition     Keiki.Core-    Keiki.Decider     Keiki.Generics     Keiki.Generics.TH     Keiki.Internal.Slots@@ -99,22 +103,31 @@     Keiki.ArrowSpec     Keiki.BuilderSpec     Keiki.BuilderSpike+    Keiki.BuilderTypeErrorsSpec     Keiki.CategorySpec     Keiki.ChoiceSpec     Keiki.CollectionSpike+    Keiki.CompositionAlignmentSpec     Keiki.CompositionAlternativeSpec     Keiki.CompositionFeedback1Spec+    Keiki.CompositionHomomorphismSpec     Keiki.CompositionMultiEventSpec     Keiki.CompositionNarySpec     Keiki.CompositionSpec+    Keiki.CompositionStatefulSpec     Keiki.CoreApplyEventsSpec     Keiki.CoreHiddenInputsGSMSpec     Keiki.CoreInFlightSpec     Keiki.CoreSpec-    Keiki.DeciderSpec+    Keiki.Fixtures.BrokenTailCoverage+    Keiki.Fixtures.ComposeStateful+    Keiki.Fixtures.CounterPipeline     Keiki.Fixtures.EmailDelivery+    Keiki.Fixtures.RegisterEmission+    Keiki.Fixtures.SplitCoverage     Keiki.Fixtures.UserRegistration     Keiki.Generics.THSpec+    Keiki.LawHelpers     Keiki.NoThunksSpec     Keiki.OperatorsQualifiedSpec     Keiki.OperatorsSpec@@ -125,10 +138,14 @@     Keiki.Render.MermaidSpec     Keiki.Render.PrettySpec     Keiki.Render.ValidateSpec+    Keiki.ReplayEitherSpec+    Keiki.RoundTrip+    Keiki.RoundTripSpec     Keiki.ShapeSpec     Keiki.StepEitherSpec     Keiki.StrongSpec     Keiki.SymbolicSpec+    Keiki.ValidationReplayAlignmentSpec     Keiki.ValidationSpec    build-depends:@@ -139,6 +156,7 @@     , keiki     , nothunks     >=0.3   && <0.4     , profunctors  >=5.6   && <6+    , QuickCheck   ^>=2.15     , sbv          >=11.7  && <15     , text         ^>=2.1     , time         ^>=1.14
src/Keiki/Acceptor.hs view
@@ -16,7 +16,7 @@ --   replayable logs. -- -- In 'Keiki.Core' these projections are /implicit/: π₁ is--- 'Keiki.Core.delta'; π₂ is 'Keiki.Core.applyEvent'. This module+-- 'Keiki.Core.delta'; π₂ is 'Keiki.Core.applyEventStreaming'. This module -- /names/ them as a first-class data type so downstream code (UI, -- validation, generated documentation) can pattern-match on a known -- shape instead of plumbing the step functions by hand.@@ -29,8 +29,7 @@ -- @ -- -- See @docs/research/acceptor-projections-design.md@ for the design--- record (deferred scope, why the state carrier is--- @(s, 'Keiki.Core.RegFile' rs)@, relationship to 'Keiki.Decider').+-- record and relationship to Core replay. module Keiki.Acceptor   ( -- * The acceptor projection     Acceptor (..),@@ -47,9 +46,10 @@  import Keiki.Core   ( BoolAlg,+    InFlight (..),     RegFile,     SymTransducer (..),-    applyEvent,+    applyEventStreaming,     delta,   ) @@ -65,7 +65,7 @@ --   terminates in a state for which this predicate holds. -- -- The richer return type of 'Keiki.Core.delta' /--- 'Keiki.Core.applyEvent' (which thread an updated 'RegFile') is+-- 'Keiki.Core.applyEventStreaming' (which thread an updated 'RegFile') is -- preserved by the projections in this module by hiding the register -- file inside @s@; see 'inputAcceptor' / 'outputAcceptor'. --@@ -107,29 +107,39 @@  -- | Project a 'SymTransducer' to its /output/ acceptor (π₂): the -- acceptor over the event alphabet whose step is--- 'Keiki.Core.applyEvent'.+-- 'Keiki.Core.applyEventStreaming'. ----- The state carrier is @(s, 'RegFile' rs)@ because 'applyEvent'--- itself threads the register file through replay.+-- The state carrier is @('Keiki.Core.InFlight' s co, 'RegFile' rs)@+-- because streaming replay must remember the expected tail of a+-- multi-event output chain as well as the register file. -- -- @ -- accepts (outputAcceptor t) events  ==  True -- @ ----- iff successively applying 'Keiki.Core.applyEvent' to each event--- reaches a final control vertex — equivalently, iff--- @'Keiki.Core.reconstitute' t events@ returns 'Just' a final--- @(s, regs)@. The output acceptor /is/ the @evolve@ acceptor the--- foundations chapter derives.+-- is equivalent to:+--+-- @+-- case 'Keiki.Core.reconstitute' t events of+--   Just (s, _) -> 'isFinal' t s+--   Nothing     -> False+-- @+--+-- A log rejects when an event cannot replay or when the log ends in+-- the middle of a multi-event chain, leaving a non-final 'InFlight'+-- carrier. outputAcceptor ::   (BoolAlg phi (RegFile rs, ci), Eq co) =>   SymTransducer phi rs s ci co ->-  Acceptor co (s, RegFile rs)+  Acceptor co (InFlight s co, RegFile rs) outputAcceptor t =   Acceptor-    { aStep = \(s, regs) co -> applyEvent t s regs co,-      aInitial = (initial t, initialRegs t),-      aIsFinal = \(s, _regs) -> isFinal t s+    { aStep = \(wrapper, regs) co ->+        applyEventStreaming t wrapper regs co,+      aInitial = (Settled (initial t), initialRegs t),+      aIsFinal = \(wrapper, _regs) -> case wrapper of+        Settled s -> isFinal t s+        InFlight {} -> False     }  -- | Run an 'Acceptor' over a sequence. Returns 'Just' the terminal
src/Keiki/Builder.hs view
@@ -5,7 +5,7 @@ -- builder is purely additive on top of "Keiki.Core": every edge it -- produces is a value of the existing 'Keiki.Core.Edge' type, and -- the resulting 'Keiki.Core.SymTransducer' is consumed unchanged by--- "Keiki.Acceptor", "Keiki.Composition", "Keiki.Decider",+-- "Keiki.Acceptor", "Keiki.Composition", -- "Keiki.Symbolic", and the example-side specs. -- -- == Why a builder@@ -114,12 +114,28 @@ --   the 'Keiki.Internal.Slots.Disjoint' 'GHC.TypeError.TypeError', --   which names the duplicated slot. ----- * Missing 'goto': caught at finalize time (when 'buildTransducer'---   evaluates the 'VertexBuilder' do-block) with a runtime error---   naming the source vertex and edge index.+-- * Duplicate names in the register file: caught at compile time by+--   the 'Keiki.Internal.Slots.DistinctNames' constraint on the two+--   build entry points. ----- * Multiple 'goto's in the same edge body: caught the same way.+-- * An 'emit' whose term-fields schema differs from its enclosing+--   'onCmd', or an 'emit' inside 'onEpsilon': caught at compile time. --+-- * Missing or multiple 'goto's: every declared edge is checked when+--   the transducer returned by 'buildTransducer' is first evaluated,+--   even if its source vertex is never inspected. All defects are+--   reported together with source vertices and edge indices. Use+--   'buildTransducerEither' to receive them as structured values.+--+-- * An 'emitWith' inside 'onCmd' whose explicit input constructor+--   differs in name or slot names from the enclosing constructor:+--   rejected by the same eager validation pass.+--+-- * An edge with neither 'emit' / 'emitWith' nor 'noEmit': rejected+--   by eager validation with a located structured defect directing+--   the user to declare whether the edge emits or is deliberately+--   silent.+-- -- == When to drop down to the AST -- -- Use the AST directly when:@@ -138,6 +154,10 @@ module Keiki.Builder   ( -- * Top-level entry point     buildTransducer,+    buildTransducerEither,+    BuilderDefect (..),+    BuilderError (..),+    renderBuilderErrors,      -- * Vertex-level builder     VertexBuilder,@@ -194,6 +214,9 @@   ) where +import Data.List (intercalate)+import Data.List.NonEmpty (NonEmpty)+import Data.List.NonEmpty qualified as NonEmpty import Data.Typeable (Typeable) import GHC.Records (HasField (..)) import GHC.TypeLits (KnownSymbol, Symbol)@@ -201,11 +224,12 @@   ( Cmp (..),     Edge (..),     HsPred (..),-    InCtor,+    InCtor (..),     Index,     OutFields (..),-    OutTerm,+    OutTerm (..),     RegFile,+    Slot,     SymTransducer (..),     Term (TReg),     Update (..),@@ -215,16 +239,18 @@     matchInCtor,     oNil,     pack,+    slotNamesOf,     (*:),   ) import Keiki.Core qualified as K import Keiki.Internal.Slots   ( Concat,     Disjoint,+    DistinctNames,     HasIndexN (..),     IndexN (..),+    Names,   )-import Unsafe.Coerce (unsafeCoerce) import Prelude hiding (pure, return, (>>), (>>=)) import Prelude qualified @@ -244,9 +270,10 @@ -- 'PTop' or @'matchInCtor' ic@ as guard, 'UKeep' as update, no -- output, no targets); each step in the body modifies one or more -- fields; 'finalizeEdge' validates that exactly one 'goto' was--- called and packages the result into a closed 'Edge'. The+-- called and that output intent was declared, then packages the+-- result into a closed 'Edge'. The -- existential @w@ on 'Edge''s 'update' field closes here.-data PartialEdge rs ci co v (w :: [Symbol]) = PartialEdge+data PartialEdge rs ci co v (pin :: Maybe [Slot]) (w :: [Symbol]) = PartialEdge   { peGuard :: HsPred rs ci,     peUpdate :: Update rs w ci,     -- | Output terms accumulated by 'emit' / 'emitWith' calls in@@ -258,23 +285,19 @@     -- | Reverse-order list of every 'goto' invocation in the body.     -- Finalization requires exactly one element.     peTargets :: [v],-    -- | The 'InCtor' bound by the enclosing 'onCmd', so that the-    -- 2-argument 'emit' can recover it without the user repeating-    -- it. 'Nothing' inside an 'onEpsilon' body — 'emit' there must-    -- use 'emitWith' to supply the 'InCtor' explicitly.-    peInCtor :: Maybe (PeInCtor ci)+    -- | The 'InCtor' bound by the enclosing 'onCmd', indexed by its+    -- real input-field schema. 'PinNone' marks an 'onEpsilon' body.+    pePinned :: Pinned ci pin,+    -- | Whether the body explicitly chose to emit or remain silent.+    -- Any 'emit', 'emitWith', or 'noEmit' call sets this to 'True'.+    peOutputDecided :: Bool   } --- | Existential wrapper hiding the @ifs@ slot list of an 'InCtor'.--- Stored on 'PartialEdge' by 'onCmd' and read back by 'emit'.------ This is a builder-local existential rather than a reuse of--- 'Keiki.Symbolic.SomeInCtor' because the latter carries an--- 'ExtractRegFile' constraint the builder does not need and lives--- in a module that pulls SBV; reusing it would add an SBV edge to--- every consumer of "Keiki.Builder".-data PeInCtor ci where-  PeInCtor :: InCtor ci ifs -> PeInCtor ci+-- | Whether an edge body has an enclosing input constructor. The+-- schema index preserves the equality that 'emit' needs.+data Pinned ci (pin :: Maybe [Slot]) where+  PinNone :: Pinned ci 'Nothing+  PinCtor :: InCtor ci ifs -> Pinned ci ('Just ifs)  -- | The per-edge indexed-state monad. The two phantom slot-set -- indices @(w :: [Symbol])@ (before this step) and @(w' :: [Symbol])@@@ -289,11 +312,11 @@ -- requires a separate type-class hierarchy. Instead, this module -- exports its own @(>>=)@ / @(>>)@ / 'pure' / 'return' for use -- with @QualifiedDo@.-newtype EdgeBuilder rs ci co v (w :: [Symbol]) (w' :: [Symbol]) a+newtype EdgeBuilder rs ci co v (pin :: Maybe [Slot]) (w :: [Symbol]) (w' :: [Symbol]) a   = EdgeBuilder   { runEdgeBuilder ::-      PartialEdge rs ci co v w ->-      (a, PartialEdge rs ci co v w')+      PartialEdge rs ci co v pin w ->+      (a, PartialEdge rs ci co v pin w')   }  -- * QualifiedDo bind/return exports ----------------------------------------@@ -302,9 +325,9 @@ -- the second argument's @w@ argument, and the second argument's @w'@ -- index becomes the result's @w'@. Re-export for @QualifiedDo@. (>>=) ::-  EdgeBuilder rs ci co v w1 w2 a ->-  (a -> EdgeBuilder rs ci co v w2 w3 b) ->-  EdgeBuilder rs ci co v w1 w3 b+  EdgeBuilder rs ci co v pin w1 w2 a ->+  (a -> EdgeBuilder rs ci co v pin w2 w3 b) ->+  EdgeBuilder rs ci co v pin w1 w3 b EdgeBuilder f >>= k = EdgeBuilder $ \pe ->   let (a, pe1) = f pe       EdgeBuilder g = k a@@ -314,19 +337,19 @@  -- | Sequence. Defined in terms of '(>>=)'. (>>) ::-  EdgeBuilder rs ci co v w1 w2 a ->-  EdgeBuilder rs ci co v w2 w3 b ->-  EdgeBuilder rs ci co v w1 w3 b+  EdgeBuilder rs ci co v pin w1 w2 a ->+  EdgeBuilder rs ci co v pin w2 w3 b ->+  EdgeBuilder rs ci co v pin w1 w3 b m >> n = m Keiki.Builder.>>= \_ -> n  infixl 1 >>  -- | Embed a value. Slot-set unchanged.-pure :: a -> EdgeBuilder rs ci co v w w a+pure :: a -> EdgeBuilder rs ci co v pin w w a pure a = EdgeBuilder $ \pe -> (a, pe)  -- | Synonym for 'pure'. Re-exported for @QualifiedDo@.-return :: a -> EdgeBuilder rs ci co v w w a+return :: a -> EdgeBuilder rs ci co v pin w w a return = Keiki.Builder.pure  -- * Slot writes ----------------------------------------------------------@@ -394,11 +417,11 @@ -- 'Keiki.Core.lit' / 'Keiki.Core.proj' / 'Keiki.Core.inpCtor' or -- @d.fieldName@ via 'PayloadProj' to construct it. (.=) ::-  forall name r rs ci ifs co v w.+  forall name r rs ci ifs co v pin w.   (KnownSymbol name, Disjoint '[name] w) =>   IndexN name rs r ->   Term rs ci ifs r ->-  EdgeBuilder rs ci co v w (Concat '[name] w) ()+  EdgeBuilder rs ci co v pin w (Concat '[name] w) () ix .= t = EdgeBuilder $ \pe ->   ((), pe {peUpdate = USet ix t `combine` peUpdate pe}) @@ -416,11 +439,11 @@ -- reserves operators beginning with a colon for data constructors, so a -- value-level synonym must start with another symbol — hence @=:@.) (=:) ::-  forall name r rs ci ifs co v w.+  forall name r rs ci ifs co v pin w.   (KnownSymbol name, Disjoint '[name] w) =>   IndexN name rs r ->   Term rs ci ifs r ->-  EdgeBuilder rs ci co v w (Concat '[name] w) ()+  EdgeBuilder rs ci co v pin w (Concat '[name] w) () (=:) = (.=)  infixr 6 =:@@ -428,10 +451,9 @@ -- * Termination -----------------------------------------------------------  -- | Set the edge's target vertex. Required exactly once per edge--- body; missing 'goto' produces a finalize-time runtime error--- naming the source vertex and edge index, and so does multiple--- 'goto's in the same body.-goto :: v -> EdgeBuilder rs ci co v w w ()+-- body; missing or multiple 'goto's are reported by the eager+-- validation pass, with the source vertex and edge index.+goto :: v -> EdgeBuilder rs ci co v pin w w () goto v = EdgeBuilder $ \pe ->   ((), pe {peTargets = v : peTargets pe}) @@ -442,9 +464,8 @@ -- either a per-event @\<CtorName\>TermFields rs ci@ record (emitted -- by 'Keiki.Generics.TH.deriveWireCtors') or a bare 'OutFields' -- HList constructed with '(*:)' / 'oNil'. The input-side 'InCtor'--- is recovered from the enclosing 'onCmd'; an 'emit' inside--- 'onEpsilon' (where no 'InCtor' is bound) raises a finalize-time--- error directing the user to 'emitWith'.+-- is recovered at its real schema from the enclosing 'onCmd'. An+-- 'emit' inside 'onEpsilon' is a compile error; use 'emitWith' there. -- -- == Multi-event commands (EP-19) --@@ -461,62 +482,48 @@   (ToOutFields rec rs ci ifs fs) =>   WireCtor co fs ->   rec ->-  EdgeBuilder rs ci co v w w ()-emit wc rec = EdgeBuilder $ \pe -> case peInCtor pe of-  Just (PeInCtor ic) ->-    -- 'onCmd' pins the same 'InCtor' into 'peInCtor' /and/ into the-    -- 'PayloadProj' the body projects through, so the record's input-    -- field schema 'ifs' (from 'ToOutFields') equals the pinned-    -- 'InCtor''s schema. The existential 'PeInCtor' hides that-    -- equality; 'reIndexPinnedInCtor' re-establishes it. This does not-    -- weaken replay soundness: the resulting 'OPack''s 'InCtor' and-    -- 'OutFields' share 'ifs', so 'solveOutput' recovers fields with no-    -- coercion (EP-53). Mirrors 'Keiki.Composition.unsafeCoerceInCtor'.+  EdgeBuilder rs ci co v ('Just ifs) w w ()+emit wc rec = EdgeBuilder $ \pe -> case pePinned pe of+  PinCtor ic ->     ( (),       pe         { peOutput =             peOutput pe-              ++ [pack (reIndexPinnedInCtor @ci @_ @ifs ic) wc (toOutFields rec)]+              ++ [pack ic wc (toOutFields rec)],+          peOutputDecided = True         }     )-  Nothing ->-    error-      "Keiki.Builder.emit: no enclosing onCmd pinned an InCtor. \-      \Use 'emitWith ic wc fs' inside 'onEpsilon', or move the \-      \emit inside an 'onCmd' block." --- | Re-establish the (existentially hidden) equality between a pinned--- 'InCtor''s field schema and the schema the enclosing 'onCmd''s--- 'PayloadProj' exposes. Unsound in general; justified at the single--- 'emit' call site by 'onCmd' storing one and the same 'InCtor' in both--- places (see 'emit'). The runtime representation is identical.-reIndexPinnedInCtor :: forall ci ifs0 ifs. InCtor ci ifs0 -> InCtor ci ifs-reIndexPinnedInCtor = unsafeCoerce---- | Emit an event with an explicit 'InCtor'. The escape hatch for--- 'onEpsilon' bodies (which do not pin an 'InCtor') and for any--- caller that needs to override the one bound by the enclosing--- 'onCmd'. Inside 'onCmd' the InCtor-less 'emit' is preferred.--- Like 'emit', accumulates into the edge's output list — multiple--- calls produce a multi-event edge.+-- | Emit an event with an explicit 'InCtor'. This is the output form+-- for 'onEpsilon' bodies, which have no pinned constructor. Inside+-- 'onCmd', the supplied constructor must have the same name and slot+-- names as the pinned constructor; 'buildTransducer' rejects a+-- mismatch eagerly because it would invert replay to another command.+-- The InCtor-less 'emit' is preferred inside 'onCmd'. Like 'emit',+-- accumulates into the edge's output list — multiple calls produce a+-- multi-event edge. emitWith ::-  forall co fs rs ci v w ifs rec.+  forall co fs rs ci v pin w ifs rec.   (ToOutFields rec rs ci ifs fs) =>   InCtor ci ifs ->   WireCtor co fs ->   rec ->-  EdgeBuilder rs ci co v w w ()+  EdgeBuilder rs ci co v pin w w () emitWith ic wc rec = EdgeBuilder $ \pe ->-  ((), pe {peOutput = peOutput pe ++ [pack ic wc (toOutFields rec)]})+  ( (),+    pe+      { peOutput = peOutput pe ++ [pack ic wc (toOutFields rec)],+        peOutputDecided = True+      }+  ) --- | Mark the edge as ε-output (no event). Idempotent: an edge with--- no 'emit' or 'noEmit' call is also an ε-edge by default; 'noEmit'--- exists only so the user can be explicit about intent. Mixing--- 'noEmit' and 'emit' in the same body is allowed but the 'noEmit'--- is a documentation no-op (the 'emit's still produce a non-empty--- output list).-noEmit :: EdgeBuilder rs ci co v w w ()-noEmit = EdgeBuilder $ \pe -> ((), pe)+-- | Declare the edge deliberately silent (an ε-edge with+-- @output = []@). Every body that does not call 'emit' or 'emitWith'+-- must call 'noEmit'; otherwise eager builder validation rejects the+-- edge. Mixing 'noEmit' and 'emit' in one body is allowed, and the+-- emitted terms still populate the output list.+noEmit :: EdgeBuilder rs ci co v pin w w ()+noEmit = EdgeBuilder $ \pe -> ((), pe {peOutputDecided = True})  -- * Field-keyed record sugar --------------------------------------------- @@ -555,7 +562,7 @@ -- Use this when the structural sugar of 'requireEq' is not enough -- (e.g. for negated predicates, disjunctions, or guards constructed -- by helper functions).-requireGuard :: HsPred rs ci -> EdgeBuilder rs ci co v w w ()+requireGuard :: HsPred rs ci -> EdgeBuilder rs ci co v pin w w () requireGuard p = EdgeBuilder $ \pe ->   ((), pe {peGuard = PAnd (peGuard pe) p}) @@ -565,7 +572,7 @@   (Eq r, Typeable r) =>   Term rs ci ifs1 r ->   Term rs ci ifs2 r ->-  EdgeBuilder rs ci co v w w ()+  EdgeBuilder rs ci co v pin w w () requireEq a b = requireGuard (PEq a b)  -- | Conjoin an ordering predicate (@a `op` b@ for the relation named@@ -579,7 +586,7 @@   Cmp ->   Term rs ci ifs1 r ->   Term rs ci ifs2 r ->-  EdgeBuilder rs ci co v w w ()+  EdgeBuilder rs ci co v pin w w () requireCmp op a b = requireGuard (PCmp op a b)  -- | Require @a < b@. See 'requireCmp'.@@ -590,7 +597,7 @@     (Ord r, Typeable r) =>     Term rs ci ifs1 r ->     Term rs ci ifs2 r ->-    EdgeBuilder rs ci co v w w ()+    EdgeBuilder rs ci co v pin w w () requireLt = requireCmp CmpLt requireLe = requireCmp CmpLe requireGt = requireCmp CmpGt@@ -635,8 +642,8 @@ newtype EdgeListBuilder rs ci co v a = EdgeListBuilder   { runEdgeListBuilder ::       v ->-      [Edge (HsPred rs ci) rs ci co v] ->-      (a, [Edge (HsPred rs ci) rs ci co v])+      [Either BuilderDefect (Edge (HsPred rs ci) rs ci co v)] ->+      (a, [Either BuilderDefect (Edge (HsPred rs ci) rs ci co v)])   }  instance Functor (EdgeListBuilder rs ci co v) where@@ -662,22 +669,21 @@ -- into a closed 'Edge'. onCmd ::   forall ci ifs rs co v w.-  (Show v) =>   InCtor ci ifs ->-  (PayloadProj rs ci ifs -> EdgeBuilder rs ci co v '[] w ()) ->+  (PayloadProj rs ci ifs -> EdgeBuilder rs ci co v ('Just ifs) '[] w ()) ->   EdgeListBuilder rs ci co v ()-onCmd ic body = EdgeListBuilder $ \src acc ->+onCmd ic body = EdgeListBuilder $ \_src acc ->   let initial =         PartialEdge           { peGuard = matchInCtor ic,             peUpdate = UKeep,             peOutput = [],             peTargets = [],-            peInCtor = Just (PeInCtor ic)+            pePinned = PinCtor ic,+            peOutputDecided = False           }       (_, finalPE) = runEdgeBuilder (body (PayloadProj ic)) initial-      edgeIx = length acc-      edge = finalizeEdge edgeIx src finalPE+      edge = finalizeEdge finalPE    in ((), edge : acc)  -- | ε-edge entry: no input projection, no input-ctor match-guard.@@ -688,61 +694,89 @@ -- directly with an explicit 'InCtor' if needed. onEpsilon ::   forall rs ci co v w.-  (Show v) =>-  EdgeBuilder rs ci co v '[] w () ->+  EdgeBuilder rs ci co v 'Nothing '[] w () ->   EdgeListBuilder rs ci co v ()-onEpsilon body = EdgeListBuilder $ \src acc ->+onEpsilon body = EdgeListBuilder $ \_src acc ->   let initial =         PartialEdge           { peGuard = PTop,             peUpdate = UKeep,             peOutput = [],             peTargets = [],-            peInCtor = Nothing+            pePinned = PinNone,+            peOutputDecided = False           }       (_, finalPE) = runEdgeBuilder body initial-      edgeIx = length acc-      edge = finalizeEdge edgeIx src finalPE+      edge = finalizeEdge finalPE    in ((), edge : acc) +-- | One structural problem found while closing a single edge body.+data BuilderDefect+  = -- | The body never called 'goto'.+    DefectMissingGoto+  | -- | The body called 'goto' more than once; carries the count.+    DefectMultipleGoto Int+  | -- | An output constructor contradicts the enclosing 'onCmd'.+    DefectOutputCtorMismatch String [String] String [String]+  | -- | The body neither emitted nor explicitly declared silence.+    DefectMissingOutputIntent+  deriving stock (Eq, Show)++-- | A defect located at a specific edge of a specific source vertex.+-- The edge index is assigned after duplicate-'from' merging.+data BuilderError v = BuilderError+  { beVertex :: v,+    beEdgeIndex :: Int,+    beDefect :: BuilderDefect+  }+  deriving stock (Eq, Show)+ -- | Close a 'PartialEdge' into an 'Edge'. Validation: 'peTargets'--- must have exactly one entry; missing or duplicated 'goto' calls--- raise a runtime 'error' naming the source vertex and edge index.+-- must have exactly one entry; missing or duplicated 'goto' calls are+-- returned structurally. A single-target edge must also declare output+-- intent with 'emit', 'emitWith', or 'noEmit'; otherwise finalization+-- returns 'DefectMissingOutputIntent'. 'buildTransducerEither' attaches location. -- The 'peOutput' list (zero or more 'OutTerm's accumulated by -- 'emit' / 'emitWith' calls) flows directly into the resulting -- 'Edge.output' field. finalizeEdge ::-  (Show v) =>-  Int ->-  v ->-  PartialEdge rs ci co v w ->-  Edge (HsPred rs ci) rs ci co v-finalizeEdge n src pe = case peTargets pe of-  [t] ->-    Edge-      { guard = peGuard pe,-        update = peUpdate pe,-        output = peOutput pe,-        target = t-      }-  [] ->-    error $-      "Keiki.Builder: edge #"-        <> show n-        <> " from "-        <> show src-        <> ": goto missing. Each onCmd/"-        <> "onEpsilon body must end with exactly one goto V."-  (_ : _ : _) ->-    error $-      "Keiki.Builder: edge #"-        <> show n-        <> " from "-        <> show src-        <> ": goto called more than once. "-        <> "Each onCmd/onEpsilon body must end with "-        <> "exactly one goto V."+  PartialEdge rs ci co v pin w ->+  Either BuilderDefect (Edge (HsPred rs ci) rs ci co v)+finalizeEdge pe = case peTargets pe of+  [t] -> case outputCtorMismatch (pePinned pe) (peOutput pe) of+    Just defect -> Left defect+    Nothing+      | not (peOutputDecided pe) -> Left DefectMissingOutputIntent+      | otherwise ->+          Right+            Edge+              { guard = peGuard pe,+                update = peUpdate pe,+                output = peOutput pe,+                target = t+              }+  [] -> Left DefectMissingGoto+  ts@(_ : _ : _) -> Left (DefectMultipleGoto (length ts)) +outputCtorMismatch :: Pinned ci pin -> [OutTerm rs ci co] -> Maybe BuilderDefect+outputCtorMismatch PinNone _ = Nothing+outputCtorMismatch (PinCtor pinned) outputs = go outputs+  where+    expectedName = icName pinned+    expectedSlots = slotNamesOf pinned++    go [] = Nothing+    go (OPack actual _ _ : rest)+      | icName actual /= expectedName || slotNamesOf actual /= expectedSlots =+          Just+            ( DefectOutputCtorMismatch+                expectedName+                expectedSlots+                (icName actual)+                (slotNamesOf actual)+            )+      | otherwise = go rest+ -- * Vertex builder --------------------------------------------------------  -- | Top-level builder. Accumulates @[(v, [Edge ...])]@ entries, one@@ -751,8 +785,8 @@ -- default for unmentioned vertices. newtype VertexBuilder rs ci co v a = VertexBuilder   { runVertexBuilder ::-      [(v, [Edge (HsPred rs ci) rs ci co v])] ->-      (a, [(v, [Edge (HsPred rs ci) rs ci co v])])+      [(v, [Either BuilderDefect (Edge (HsPred rs ci) rs ci co v)])] ->+      (a, [(v, [Either BuilderDefect (Edge (HsPred rs ci) rs ci co v)])])   }  instance Functor (VertexBuilder rs ci co v) where@@ -780,7 +814,6 @@ -- (terminal). To assert "this vertex is terminal" explicitly, -- write @from V (Prelude.pure ())@. from ::-  (Eq v, Show v) =>   v ->   EdgeListBuilder rs ci co v () ->   VertexBuilder rs ci co v ()@@ -799,24 +832,120 @@ -- returns the concatenation of every entry's edges in declaration -- order. ----- The @Bounded v@ / @Enum v@ constraints are not currently used by--- 'buildTransducer' itself but are recorded as reserved for a--- future @withCompletenessCheck@ combinator that would assert every--- vertex appears in some 'from' block.+-- Every declared edge is validated before the result is returned.+-- Forcing the returned transducer to weak head normal form therefore+-- reports malformed edges even when their source vertices are never+-- otherwise inspected. Use 'buildTransducerEither' for structured+-- diagnostics instead of an exception. buildTransducer ::   forall rs ci co v.-  (Bounded v, Enum v, Eq v, Show v) =>+  (DistinctNames (Names rs), Eq v, Show v) =>   v ->   RegFile rs ->   (v -> Bool) ->   VertexBuilder rs ci co v () ->   SymTransducer (HsPred rs ci) rs v ci co buildTransducer initS initR isF vb =-  SymTransducer-    { edgesOut = \v -> Prelude.concatMap snd (Prelude.filter ((== v) . fst) vmap),-      initial = initS,-      initialRegs = initR,-      isFinal = isF-    }+  case buildTransducerEither initS initR isF vb of+    Left errs -> error (renderBuilderErrors errs)+    Right tr -> tr++-- | Validating entry point. Runs the complete 'VertexBuilder', merges+-- duplicate vertices in declaration order, assigns stable per-vertex+-- edge indices, and returns every structural defect found.+buildTransducerEither ::+  forall rs ci co v.+  (DistinctNames (Names rs), Eq v) =>+  v ->+  RegFile rs ->+  (v -> Bool) ->+  VertexBuilder rs ci co v () ->+  Either+    (NonEmpty (BuilderError v))+    (SymTransducer (HsPred rs ci) rs v ci co)+buildTransducerEither initS initR isF vb =+  forceBuilderErrors errors `seq`+    forceEdgeTable cleanTable `seq`+      case NonEmpty.nonEmpty errors of+        Just errs -> Left errs+        Nothing ->+          Right+            SymTransducer+              { edgesOut = \v -> maybe [] id (lookup v cleanTable),+                initial = initS,+                initialRegs = initR,+                isFinal = isF+              }   where-    (_, vmap) = runVertexBuilder vb []+    (_, rawEntries) = runVertexBuilder vb []+    mergedEntries = foldl' mergeEntry [] (Prelude.reverse rawEntries)+    locatedEntries =+      [ (v, zipWith (locate v) [0 ..] results)+      | (v, results) <- mergedEntries+      ]+    errors =+      [ builderError+      | (_, results) <- locatedEntries,+        Left builderError <- results+      ]+    cleanTable =+      [ (v, [edge | Right edge <- results])+      | (v, results) <- locatedEntries+      ]++    mergeEntry acc (v, edges) =+      case break ((== v) . fst) acc of+        (_, []) -> acc ++ [(v, edges)]+        (before, (existingV, existingEdges) : after) ->+          before ++ (existingV, existingEdges ++ edges) : after++    locate v edgeIndex = \case+      Left defect -> Left (BuilderError v edgeIndex defect)+      Right edge -> Right edge++-- | Render structured builder errors in the historical message format.+-- Multiple errors are joined by newlines in declaration order.+renderBuilderErrors :: (Show v) => NonEmpty (BuilderError v) -> String+renderBuilderErrors = intercalate "\n" . fmap renderBuilderError . NonEmpty.toList++renderBuilderError :: (Show v) => BuilderError v -> String+renderBuilderError (BuilderError src n defect) =+  "Keiki.Builder: edge #"+    <> show n+    <> " from "+    <> show src+    <> case defect of+      DefectMissingGoto ->+        ": goto missing. Each onCmd/onEpsilon body must end with exactly one goto V."+      DefectMultipleGoto _ ->+        ": goto called more than once. Each onCmd/onEpsilon body must end with exactly one goto V."+      DefectOutputCtorMismatch expectedName expectedSlots actualName actualSlots ->+        ": emitWith InCtor "+          <> show actualName+          <> " (slots "+          <> renderSlotNames actualSlots+          <> ") contradicts the enclosing onCmd's InCtor "+          <> show expectedName+          <> " (slots "+          <> renderSlotNames expectedSlots+          <> "). An onCmd edge's outputs must pack the command constructor the edge matches on, or replay will invert the event to a different command."+      DefectMissingOutputIntent ->+        ": no emit or noEmit. Each onCmd/onEpsilon body must call 'emit' (or 'emitWith') to produce an event, or 'noEmit' to declare the edge deliberately silent (ε-edge)."++renderSlotNames :: [String] -> String+renderSlotNames names = "[" <> intercalate "," names <> "]"++forceBuilderErrors :: [BuilderError v] -> ()+forceBuilderErrors [] = ()+forceBuilderErrors (builderError : rest) = builderError `seq` forceBuilderErrors rest++forceEdgeTable :: [(v, [Edge p rs ci co v])] -> ()+forceEdgeTable [] = ()+forceEdgeTable ((_, edges) : rest) = forceEdges edges `seq` forceEdgeTable rest+  where+    forceEdges [] = ()+    forceEdges (edge : more) =+      edge `seq` forceOutputSpine (output edge) `seq` forceEdges more++    forceOutputSpine [] = ()+    forceOutputSpine (_ : outputs) = forceOutputSpine outputs
src/Keiki/Composition.hs view
@@ -4,16 +4,27 @@ -- slot-name domains; the body uses raw 'UCombine' (decision logged -- in EP-18) so GHC sees the constraint as unused. Same reasoning as -- "Keiki.Core"'s 'combine'.-{-# OPTIONS_GHC -Wno-redundant-constraints #-}+{-# OPTIONS_GHC -Wno-partial-fields -Wno-redundant-constraints #-}  -- | Sequential composition of two 'SymTransducer's. ----- The single user-facing value is 'compose'. Given a transducer @t1@+-- Stability: experimental. The categorical representation may change before+-- its full law contract is resolved. For validated aggregate pipelines prefer+-- 'composeChecked'; 'compose' remains the unchecked construction primitive.+--+-- Given a transducer @t1@ -- whose output alphabet is @mid@ and a transducer @t2@ whose input -- alphabet is also @mid@, @compose t1 t2@ is the composite transducer -- whose input is t1's input, whose output is t2's output, whose -- vertex is the pair (wrapped in 'Composite' so 'Bounded'/'Enum' -- derive cleanly), and whose register file is @'Append' rs1 rs2@.+-- Update right-hand sides use the entry register snapshot. For a+-- multi-event t1 edge, path expansion symbolically threads each t2 write+-- into later t2 guards, updates, and outputs before collapsing the path.+-- A comparison leaf that reads a different mid constructor becomes+-- 'PBot'; mismatched reads in other positions become opaque poison terms+-- that structural validation and rendering can inspect safely and whose+-- values raise only if an unsatisfiable edge nevertheless demands them. -- -- See @docs/research/composition-combinators-design.md@ for the -- formal semantics, the substitution algorithm, the proof sketch@@ -30,6 +41,9 @@      -- * Sequential composition     compose,+    ComposeAlignmentWarning (..),+    checkComposeAlignment,+    composeChecked,      -- * Disjoint-input dispatch     alternative,@@ -48,6 +62,14 @@     weakenRUpdate,     weakenROutFields, +    -- * Slot-list witnesses+    SlotListWitness (..),+    KnownSlots (..),+    appendWitness,+    withKnownSlots,+    withDisjointNil,+    witnessNames,+     -- * Substitution (exposed for advanced uses)     substTerm,     substPred,@@ -84,6 +106,10 @@   ) where +import Data.List (isInfixOf, isSuffixOf, nub)+import Data.Set qualified as Set+import Data.Type.Equality ((:~:) (Refl))+import GHC.TypeLits (KnownSymbol) import Keiki.Core import Keiki.Generics (Append, appendRegFile) import NoThunks.Class (NoThunks (..), allNoThunks)@@ -96,7 +122,7 @@ -- instances on @(s1, s2)@ — those would conflict with downstream -- code. data Composite s1 s2 = Composite !s1 !s2-  deriving (Eq, Show)+  deriving (Eq, Ord, Show)  instance (Bounded s1, Bounded s2) => Bounded (Composite s1 s2) where   minBound = Composite minBound minBound@@ -158,6 +184,59 @@   weakenR i = SIdx (weakenR @rs1 i)   weakenRIndexN i = IS (weakenRIndexN @rs1 i) +-- * Slot-list witnesses ---------------------------------------------------++-- | Value-level singleton of a slot-list spine. 'WNil' mirrors @'[]@;+-- 'WCons' mirrors one cons cell, capturing the slot name's+-- 'KnownSymbol'. Packed by 'Keiki.Profunctor.SomeSymTransducer' at+-- wrap time (where @rs@ is concrete) so that instance dictionaries+-- for hidden slot lists can later be re-derived by structural+-- recursion instead of fabricated with @unsafeCoerce@.+data SlotListWitness (rs :: [Slot]) where+  WNil :: SlotListWitness '[]+  WCons :: (KnownSymbol s) => SlotListWitness rs -> SlotListWitness ('(s, t) ': rs)++-- | Conjure a 'SlotListWitness' for a concrete slot list. The+-- superclasses bundle the two structural classes every wrapper+-- consumer needs, so a packed @KnownSlots rs@ also supplies+-- 'WeakenR' and 'KnownSlotNames'.+class (WeakenR rs, KnownSlotNames rs) => KnownSlots (rs :: [Slot]) where+  slotWitness :: SlotListWitness rs++instance KnownSlots '[] where+  slotWitness = WNil++instance (KnownSymbol s, KnownSlots rs) => KnownSlots ('(s, t) ': rs) where+  slotWitness = WCons (slotWitness @rs)++-- | Append two witnesses. This is the value-level mirror of+-- 'Keiki.Generics.appendRegFile'; each equation matches one+-- 'Append' reduction step.+appendWitness ::+  SlotListWitness rs1 ->+  SlotListWitness rs2 ->+  SlotListWitness (Append rs1 rs2)+appendWitness WNil w2 = w2+appendWitness (WCons w1) w2 = WCons (appendWitness w1 w2)++-- | Discharge @KnownSlots rs@, and therefore 'WeakenR rs' and+-- 'KnownSlotNames rs', from a witness by induction on its spine.+withKnownSlots :: SlotListWitness rs -> ((KnownSlots rs) => r) -> r+withKnownSlots WNil k = k+withKnownSlots (WCons w) k = withKnownSlots w k++-- | Discharge @Disjoint (Names rs) '[]@: no slot name collides with+-- the empty list. This replaces the fabricated evidence formerly+-- used by 'Keiki.Profunctor.left''.+withDisjointNil :: SlotListWitness rs -> ((Disjoint (Names rs) '[]) => r) -> r+withDisjointNil WNil k = k+withDisjointNil (WCons w) k = withDisjointNil w k++-- | Return the names described by a witness, using the same induction+-- that re-derives the structural dictionaries.+witnessNames :: forall rs. SlotListWitness rs -> [String]+witnessNames w = withKnownSlots w (slotNames @rs)+ -- * weakenL: lift an Index over rs1 to (Append rs1 rs2) -------------------  -- | Lift a head-side 'Index' across an rs2 suffix. Walks the@@ -204,6 +283,8 @@     (weakenLTerm @rs1 @rs2 a)     (weakenLTerm @rs1 @rs2 b) weakenLPred (PInCtor ic) = PInCtor ic+weakenLPred PLeftArm = PLeftArm+weakenLPred PRightArm = PRightArm weakenLPred (PCmp op a b) =   PCmp     op@@ -277,6 +358,8 @@     (weakenRTerm @rs1 @rs2 a)     (weakenRTerm @rs1 @rs2 b) weakenRPred (PInCtor ic) = PInCtor ic+weakenRPred PLeftArm = PLeftArm+weakenRPred PRightArm = PRightArm weakenRPred (PCmp op a b) =   PCmp     op@@ -369,6 +452,27 @@   | n > 0 = nthTerm (n - 1) rest   | otherwise = Nothing +-- | A structurally inert term whose value raises only when demanded.+-- Structural walkers see an opaque 'TApp1' over a harmless literal, so a+-- constructor-mismatched composite edge cannot crash validation or rendering.+poisonTerm :: String -> Term rs ci ifs r+poisonTerm message = TApp1 (\() -> error message) (TLit ())++-- | Detect a field read for a constructor other than the mid constructor+-- produced by the t1 output currently being substituted.+termHasCtorMismatch :: String -> Term rs ci ifs r -> Bool+termHasCtorMismatch _ (TLit _) = False+termHasCtorMismatch _ (TReg _) = False+termHasCtorMismatch expected (TInpCtorField ic _) = icName ic /= expected+termHasCtorMismatch expected (TApp1 _ term) = termHasCtorMismatch expected term+termHasCtorMismatch expected (TArith _ a b) =+  termHasCtorMismatch expected a || termHasCtorMismatch expected b+termHasCtorMismatch expected (TApp2 _ a b) =+  termHasCtorMismatch expected a || termHasCtorMismatch expected b++outCtorName :: OutTerm rs ci co -> String+outCtorName (OPack _ wc _) = wcName wc+ -- | Substitute a t2-side 'Term' against t1's edge output. See the -- design note's "Substituting a Term" section for the rules. --@@ -399,7 +503,7 @@                   -- type and the input field schema.                   weakenLTerm @rs1 @rs2 (unsafeCoerceTerm tm)                 Nothing ->-                  error+                  poisonTerm                     ( "Keiki.Composition.compose: nthTerm overflow at\                       \ position "                         <> show n@@ -411,16 +515,14 @@                            \ mid type."                     )       | otherwise ->-          error-            ( "Keiki.Composition.compose: TInpCtorField over "+          poisonTerm+            ( "Keiki.Composition.compose: t2-side guard, update, or output reads "                 <> icName ic2-                <> " but t1's edge produced "+                <> " while t1's edge carries "                 <> wcName wc1-                <> " — caller should ensure structural alignment of mid's\-                   \ constructors. Substitution at this position is\-                   \ unsound; the composite edge guard's PInCtor\-                   \ substitution should make the edge unsatisfiable\-                   \ before evaluation reaches this term."+                <> ". This composite edge cannot supply that constructor field;\+                   \ its mismatched guard leaf should be unsatisfiable before\+                   \ the value is demanded."             ) substTerm (TApp1 f t) o1 = TApp1 f (substTerm @rs1 @rs2 t o1) substTerm (TArith op a b) o1 =@@ -463,20 +565,64 @@     (substPred @rs1 @rs2 q o1) substPred (PNot p) o1 = PNot (substPred @rs1 @rs2 p o1) substPred (PEq a b) o1 =-  PEq-    (substTerm @rs1 @rs2 a o1)-    (substTerm @rs1 @rs2 b o1)+  if termHasCtorMismatch (outCtorName o1) a+    || termHasCtorMismatch (outCtorName o1) b+    then PBot+    else+      PEq+        (substTerm @rs1 @rs2 a o1)+        (substTerm @rs1 @rs2 b o1) substPred (PCmp op a b) o1 =-  PCmp-    op-    (substTerm @rs1 @rs2 a o1)-    (substTerm @rs1 @rs2 b o1)+  if termHasCtorMismatch (outCtorName o1) a+    || termHasCtorMismatch (outCtorName o1) b+    then PBot+    else+      PCmp+        op+        (substTerm @rs1 @rs2 a o1)+        (substTerm @rs1 @rs2 b o1) substPred (PInCtor ic2) o1 =   case o1 of     OPack _ wc1 _       | icName ic2 == wcName wc1 -> PTop       | otherwise -> PBot+substPred PLeftArm o1 = substLeftArmPred @rs1 @rs2 o1+substPred PRightArm o1 = substRightArmPred @rs1 @rs2 o1 +-- | Preserve concrete arm tests when a sum-valued intermediate is+-- substituted away. The wire builder reconstructs the intermediate value+-- from a structural term for its fields; the resulting opaque term is exact+-- for forward evaluation (and conservatively opaque to the solver).+substLeftArmPred ::+  forall rs1 rs2 ci1 mid1 mid2.+  (WeakenR rs1) =>+  OutTerm rs1 ci1 (Either mid1 mid2) ->+  HsPred (Append rs1 rs2) ci1+substLeftArmPred (OPack _ wc fields) =+  PEq+    ( TApp1+        (\fs -> case wcBuild wc fs of Left _ -> True; Right _ -> False)+        (weakenLTerm @rs1 @rs2 (outFieldsAsTerm fields))+    )+    (TLit True)++substRightArmPred ::+  forall rs1 rs2 ci1 mid1 mid2.+  (WeakenR rs1) =>+  OutTerm rs1 ci1 (Either mid1 mid2) ->+  HsPred (Append rs1 rs2) ci1+substRightArmPred (OPack _ wc fields) =+  PEq+    ( TApp1+        (\fs -> case wcBuild wc fs of Left _ -> False; Right _ -> True)+        (weakenLTerm @rs1 @rs2 (outFieldsAsTerm fields))+    )+    (TLit True)++outFieldsAsTerm :: OutFields rs ci ifs fs -> Term rs ci ifs fs+outFieldsAsTerm OFNil = TLit ()+outFieldsAsTerm (OFCons term rest) = TApp2 (,) term (outFieldsAsTerm rest)+ -- | Substitute a t2-side 'Update' against t1's edge output. The -- slot-name index @w@ is preserved by substitution — substituting -- input reads inside the right-hand-side 'Term's does not change@@ -645,6 +791,8 @@     (liftLTermAlt @rs @ci1 @ci2 a)     (liftLTermAlt @rs @ci1 @ci2 b) liftLPredAlt (PInCtor ic) = PInCtor (leftInCtor ic)+liftLPredAlt PLeftArm = PInCtor (liftedArmInCtor "left" (\case Left value -> Just value; Right _ -> Nothing) True)+liftLPredAlt PRightArm = PInCtor (liftedArmInCtor "left" (\case Left value -> Just value; Right _ -> Nothing) False) liftLPredAlt (PCmp op a b) =   PCmp     op@@ -672,12 +820,29 @@     (liftRTermAlt @rs @ci1 @ci2 a)     (liftRTermAlt @rs @ci1 @ci2 b) liftRPredAlt (PInCtor ic) = PInCtor (rightInCtor ic)+liftRPredAlt PLeftArm = PInCtor (liftedArmInCtor "right" (\case Left _ -> Nothing; Right value -> Just value) True)+liftRPredAlt PRightArm = PInCtor (liftedArmInCtor "right" (\case Left _ -> Nothing; Right value -> Just value) False) liftRPredAlt (PCmp op a b) =   PCmp     op     (liftRTermAlt @rs @ci1 @ci2 a)     (liftRTermAlt @rs @ci1 @ci2 b) +liftedArmInCtor ::+  String ->+  (outer -> Maybe (Either inner1 inner2)) ->+  Bool ->+  InCtor outer '[]+liftedArmInCtor outerName project wantLeft =+  InCtor+    { icName = "keiki#" <> outerName <> "#" <> (if wantLeft then "leftArm#lmapped" else "rightArm#lmapped"),+      icMatch = \outer -> case project outer of+        Just (Left _) | wantLeft -> Just RNil+        Just (Right _) | not wantLeft -> Just RNil+        _ -> Nothing,+      icBuild = \_ -> error "Keiki.Composition: nested lifted arm predicates cannot rebuild inputs"+    }+ -- | Lift an 'Update' from the left side's input alphabet to -- @Either ci1 ci2@. The slot-name index @w@ is preserved; only the -- right-hand-side 'Term's are walked.@@ -829,6 +994,100 @@  -- * Multi-event composition (EP-19 M6) ----------------------------------- +-- | A symbolic write performed by an earlier t2 step in a multi-event+-- composition path. The value term has already been substituted into the+-- composite register/input domain. Its input-field schema is existential+-- because updates never expose that schema.+data PendingWrite rs ci where+  PendingWrite ::+    (KnownSymbol s) =>+    IndexN s rs r ->+    Term rs ci ifs r ->+    PendingWrite rs ci++-- | Compare a slot-name-tagged update index with a positional register+-- index. Equal positions refine the stored value types to equality.+matchIndex :: IndexN s rs a -> Index rs b -> Maybe (a :~: b)+matchIndex IZ ZIdx = Just Refl+matchIndex (IS i) (SIdx j) = matchIndex i j+matchIndex _ _ = Nothing++-- | Look up the most recent symbolic write to a register. The environment+-- is newest-first; 'unsafeCoerceTerm' only realigns the existential input+-- field schema, under the same justification used by 'substTerm'.+lookupPending ::+  Index rs r ->+  [PendingWrite rs ci] ->+  Maybe (Term rs ci ifs r)+lookupPending _ [] = Nothing+lookupPending ix (PendingWrite pendingIx pendingTerm : rest) =+  case matchIndex pendingIx ix of+    Just Refl -> Just (unsafeCoerceTerm pendingTerm)+    Nothing -> lookupPending ix rest++-- | Inline earlier t2 writes so a later chain step observes the symbolic+-- register state that sequential execution would have produced.+applyEnvTerm ::+  [PendingWrite rs ci] ->+  Term rs ci ifs r ->+  Term rs ci ifs r+applyEnvTerm _ (TLit r) = TLit r+applyEnvTerm env (TReg ix) = maybe (TReg ix) id (lookupPending ix env)+applyEnvTerm _ (TInpCtorField ic ix) = TInpCtorField ic ix+applyEnvTerm env (TApp1 f term) = TApp1 f (applyEnvTerm env term)+applyEnvTerm env (TArith op a b) =+  TArith op (applyEnvTerm env a) (applyEnvTerm env b)+applyEnvTerm env (TApp2 f a b) =+  TApp2 f (applyEnvTerm env a) (applyEnvTerm env b)++applyEnvPred ::+  [PendingWrite rs ci] ->+  HsPred rs ci ->+  HsPred rs ci+applyEnvPred _ PTop = PTop+applyEnvPred _ PBot = PBot+applyEnvPred env (PAnd a b) = PAnd (applyEnvPred env a) (applyEnvPred env b)+applyEnvPred env (POr a b) = POr (applyEnvPred env a) (applyEnvPred env b)+applyEnvPred env (PNot pred') = PNot (applyEnvPred env pred')+applyEnvPred env (PEq a b) = PEq (applyEnvTerm env a) (applyEnvTerm env b)+applyEnvPred env (PCmp op a b) =+  PCmp op (applyEnvTerm env a) (applyEnvTerm env b)+applyEnvPred _ (PInCtor ic) = PInCtor ic+applyEnvPred _ PLeftArm = PLeftArm+applyEnvPred _ PRightArm = PRightArm++applyEnvUpdate ::+  [PendingWrite rs ci] ->+  Update rs w ci ->+  Update rs w ci+applyEnvUpdate _ UKeep = UKeep+applyEnvUpdate env (USet ix term) = USet ix (applyEnvTerm env term)+applyEnvUpdate env (UCombine a b) =+  UCombine (applyEnvUpdate env a) (applyEnvUpdate env b)++applyEnvOutFields ::+  [PendingWrite rs ci] ->+  OutFields rs ci ifs fs ->+  OutFields rs ci ifs fs+applyEnvOutFields _ OFNil = OFNil+applyEnvOutFields env (OFCons term rest) =+  OFCons (applyEnvTerm env term) (applyEnvOutFields env rest)++applyEnvOut ::+  [PendingWrite rs ci] ->+  OutTerm rs ci co ->+  OutTerm rs ci co+applyEnvOut env (OPack ic wc fields) =+  OPack ic wc (applyEnvOutFields env fields)++-- | Collect one t2 step's writes newest-first. The right half is collected+-- before the left so an internal raw 'UCombine' that repeats a slot matches+-- 'runUpdate''s rightmost-write-wins application order.+pendingWrites :: Update rs w ci -> [PendingWrite rs ci]+pendingWrites UKeep = []+pendingWrites (USet ix term) = [PendingWrite ix term]+pendingWrites (UCombine a b) = pendingWrites b ++ pendingWrites a+ -- | An in-progress t2-edge path through a multi-event 'compose' -- expansion. Carries the accumulated guard (the lifted @e1@-guard -- conjoined with each consumed t2-edge's substituted guard), the@@ -845,10 +1104,243 @@       !(HsPred (Append rs1 rs2) ci1) -- accumulated guard       !(Update (Append rs1 rs2) w ci1) -- chained update (existential w)       ![OutTerm (Append rs1 rs2) ci1 co] -- accumulated outputs in order+      ![PendingWrite (Append rs1 rs2) ci1] -- earlier t2 writes, newest first       !s2 -- t2-state after consuming so far  -- * compose ---------------------------------------------------------------- +-- | A conservative structural fact discovered at a composition boundary.+-- The checker reports concrete name/position mismatches. Its reachability scan+-- is conservative, so exotic Boolean guards can produce warnings on a+-- semantically unreachable path or hide an expectation the structural walker+-- cannot expose. Every warning is a reviewable structural fact, but an empty+-- result is not a proof about arbitrary opaque guard logic.+data ComposeAlignmentWarning s1 s2+  = UnconsumedWireOutput+      { cawT1Edge :: EdgeRef s1,+        cawWireName :: String,+        cawT2Vertex :: s2+      }+  | UnmatchedInCtorExpectation+      { cawT2Edge :: EdgeRef s2,+        cawInCtorName :: String,+        cawT1Vertex :: s1+      }+  | FieldArityMismatch+      { cawT1EdgeA :: EdgeRef s1,+        cawT2EdgeA :: EdgeRef s2,+        cawSharedName :: String,+        cawReadPosition :: Int,+        cawAvailableFields :: Int+      }+  | PoisonedNameInComposition+      { cawName :: String,+        cawSide :: String+      }+  deriving stock (Eq, Show)++data EmittedName s = EmittedName+  { emittedEdge :: EdgeRef s,+    emittedName :: String,+    emittedArity :: Int+  }++data ExpectedName s = ExpectedName+  { expectedEdge :: EdgeRef s,+    expectedName :: String,+    expectedPosition :: Maybe Int+  }++outFieldsLength :: OutFields rs ci ifs fs -> Int+outFieldsLength OFNil = 0+outFieldsLength (OFCons _ rest) = 1 + outFieldsLength rest++edgeEmittedNames :: s -> Int -> Edge p rs ci co s -> [EmittedName s]+edgeEmittedNames source edgeIx edge =+  [ EmittedName+      { emittedEdge = EdgeRef source edgeIx,+        emittedName = wcName wc,+        emittedArity = outFieldsLength fields+      }+  | OPack _ wc fields <- output edge+  ]++termExpectedReads :: Term rs ci ifs r -> [(String, Int)]+termExpectedReads (TLit _) = []+termExpectedReads (TReg _) = []+termExpectedReads (TInpCtorField ic ix) = [(icName ic, indexInt ix)]+termExpectedReads (TApp1 _ term) = termExpectedReads term+termExpectedReads (TApp2 _ a b) = termExpectedReads a ++ termExpectedReads b+termExpectedReads (TArith _ a b) = termExpectedReads a ++ termExpectedReads b++predCtorAtoms :: HsPred rs ci -> [String]+predCtorAtoms PTop = []+predCtorAtoms PBot = []+predCtorAtoms (PAnd a b) = predCtorAtoms a ++ predCtorAtoms b+predCtorAtoms (POr a b) = predCtorAtoms a ++ predCtorAtoms b+predCtorAtoms (PNot pred') = predCtorAtoms pred'+predCtorAtoms (PEq _ _) = []+predCtorAtoms (PInCtor ic) = [icName ic]+predCtorAtoms PLeftArm = []+predCtorAtoms PRightArm = []+predCtorAtoms (PCmp _ _ _) = []++predExpectedReads :: HsPred rs ci -> [(String, Int)]+predExpectedReads PTop = []+predExpectedReads PBot = []+predExpectedReads (PAnd a b) = predExpectedReads a ++ predExpectedReads b+predExpectedReads (POr a b) = predExpectedReads a ++ predExpectedReads b+predExpectedReads (PNot pred') = predExpectedReads pred'+predExpectedReads (PEq a b) = termExpectedReads a ++ termExpectedReads b+predExpectedReads (PInCtor _) = []+predExpectedReads PLeftArm = []+predExpectedReads PRightArm = []+predExpectedReads (PCmp _ a b) = termExpectedReads a ++ termExpectedReads b++updateExpectedReads :: Update rs w ci -> [(String, Int)]+updateExpectedReads UKeep = []+updateExpectedReads (USet _ term) = termExpectedReads term+updateExpectedReads (UCombine a b) = updateExpectedReads a ++ updateExpectedReads b++outFieldsExpectedReads :: OutFields rs ci ifs fs -> [(String, Int)]+outFieldsExpectedReads OFNil = []+outFieldsExpectedReads (OFCons term rest) =+  termExpectedReads term ++ outFieldsExpectedReads rest++edgeExpectedNames :: s -> Int -> Edge (HsPred rs ci) rs ci co s -> [ExpectedName s]+edgeExpectedNames source edgeIx Edge {guard = edgeGuard, update = edgeUpdate, output = edgeOutput} =+  [ ExpectedName (EdgeRef source edgeIx) name Nothing+  | name <- predCtorAtoms edgeGuard+  ]+    ++ [ ExpectedName (EdgeRef source edgeIx) name (Just position)+       | (name, position) <-+           predExpectedReads edgeGuard+             ++ updateExpectedReads edgeUpdate+             ++ concatMap (\(OPack _ _ fields) -> outFieldsExpectedReads fields) edgeOutput+       ]++edgeConsumesName :: String -> Edge (HsPred rs ci) rs ci co s -> Bool+edgeConsumesName name edge =+  null atoms || name `elem` atoms+  where+    atoms = predCtorAtoms (guard edge)++isPoisonedBoundaryName :: String -> Bool+isPoisonedBoundaryName name =+  "#lmapped" `isInfixOf` name+    || "#rmapped" `isInfixOf` name+    || "_first" `isSuffixOf` name++-- | Check constructor-name and field-position alignment before building a+-- composite. Reachable vertex pairs are expanded from the two initial+-- vertices; multi-event outputs advance the downstream machine one symbol+-- at a time, matching 'compose''s path expansion conservatively.+checkComposeAlignment ::+  forall rs1 rs2 s1 s2 ci1 mid co.+  (Bounded s1, Enum s1, Ord s1, Bounded s2, Enum s2, Ord s2) =>+  SymTransducer (HsPred rs1 ci1) rs1 s1 ci1 mid ->+  SymTransducer (HsPred rs2 mid) rs2 s2 mid co ->+  [ComposeAlignmentWarning s1 s2]+checkComposeAlignment t1 t2 = nub (concatMap warningsAt reachablePairs)+  where+    reachablePairs = Set.toList (walk Set.empty [(initial t1, initial t2)])++    walk seen [] = seen+    walk seen (pair : rest)+      | pair `Set.member` seen = walk seen rest+      | otherwise =+          walk (Set.insert pair seen) (successors pair ++ rest)++    successors (v1, v2) =+      [ (target edge1, v2')+      | edge1 <- edgesOut t1 v1,+        v2' <- case output edge1 of+          [] -> [v2]+          mids -> advance v2 mids+      ]++    advance vertex [] = [vertex]+    advance vertex (OPack _ wc _ : rest) =+      [ end+      | edge2 <- edgesOut t2 vertex,+        edgeConsumesName (wcName wc) edge2,+        end <- advance (target edge2) rest+      ]++    warningsAt (v1, v2) =+      unconsumed ++ unmatched ++ arity ++ poison+      where+        t1Edges = zip [0 ..] (edgesOut t1 v1)+        t2Edges = zip [0 ..] (edgesOut t2 v2)+        emissions = concatMap (uncurry (edgeEmittedNames v1)) t1Edges+        expectations = concatMap (uncurry (edgeExpectedNames v2)) t2Edges+        emittedNames = map emittedName emissions+        expectedNames = map expectedName expectations++        unconsumed =+          [ UnconsumedWireOutput (emittedEdge emission) (emittedName emission) v2+          | emission <- emissions,+            not (any (edgeConsumesName (emittedName emission) . snd) t2Edges)+          ]++        unmatched =+          [ UnmatchedInCtorExpectation (expectedEdge expectation) name v1+          | expectation <- expectations,+            let name = expectedName expectation,+            name `notElem` emittedNames+          ]++        arity =+          [ FieldArityMismatch+              (emittedEdge emission)+              (expectedEdge expectation)+              name+              position+              (emittedArity emission)+          | emission <- emissions,+            expectation <- expectations,+            let name = expectedName expectation,+            emittedName emission == name,+            Just position <- [expectedPosition expectation],+            position >= emittedArity emission+          ]++        poison =+          [ PoisonedNameInComposition name side+          | (name, side) <-+              [(name, "upstream output") | name <- emittedNames]+                ++ [(name, "downstream input") | name <- expectedNames],+            isPoisonedBoundaryName name+          ]++-- | Checked entry point for validated aggregate pipelines. The unchecked+-- 'compose' primitive remains available for internal/experimental use.+composeChecked ::+  forall rs1 rs2 s1 s2 ci1 mid co.+  ( WeakenR rs1,+    Disjoint (Names rs1) (Names rs2),+    Bounded s1,+    Enum s1,+    Ord s1,+    Bounded s2,+    Enum s2,+    Ord s2+  ) =>+  SymTransducer (HsPred rs1 ci1) rs1 s1 ci1 mid ->+  SymTransducer (HsPred rs2 mid) rs2 s2 mid co ->+  Either+    [ComposeAlignmentWarning s1 s2]+    ( SymTransducer+        (HsPred (Append rs1 rs2) ci1)+        (Append rs1 rs2)+        (Composite s1 s2)+        ci1+        co+    )+composeChecked t1 t2 = case checkComposeAlignment t1 t2 of+  [] -> Right (compose t1 t2)+  warnings -> Left warnings+ -- | Sequential composition of two 'SymTransducer's. The composite -- consumes t1's input alphabet and produces t2's output alphabet, -- threading t1's events through t2 transparently.@@ -861,6 +1353,16 @@ --     t2 from @s2@: one composite edge whose guard / update / --     output are t2's structurally substituted against t1's edge --     output, conjoined with t1's lifted guard / update.+--   * For a multi-event t1 edge, every t2 path is expanded in event+--     order. Earlier t2 writes are substituted into later guards,+--     updates, and outputs, so the collapsed edge observes the same+--     register snapshots as sequential t2 stepping.+--   * All writes in the final edge evaluate their right-hand sides+--     against the edge-entry snapshot; internal repeated writes are+--     applied left-to-right, making the last chain step win.+--   * A constructor-mismatched comparison leaf is unsatisfiable+--     regardless of Boolean operand order. Other mismatched term+--     positions remain structurally inert and fail only if evaluated. -- -- The composite preserves the keiki guarantees: --   * Mechanical inversion: 'solveOutput' on the composite@@ -1001,6 +1503,7 @@           (weakenLPred @rs1 @rs2 (guard e1))           (weakenLUpdate @rs1 @rs2 u1)           []+          []           s2      -- \| Enumerate all t2-edge paths that consume the supplied@@ -1019,9 +1522,9 @@     expandPaths [] path = [path]     expandPaths (o : rest) path =       case path of-        PartialPath g u outs s2 ->+        PartialPath g u outs env s2 ->           concatMap-            (\e2 -> expandPaths rest (stepPath g u outs o s2 e2))+            (\e2 -> expandPaths rest (stepPath g u outs env o s2 e2))             (edgesOut t2 s2)      -- \| Extend a path by one t2-edge consuming one mid-symbol.@@ -1033,17 +1536,28 @@       HsPred (Append rs1 rs2) ci1 ->       Update (Append rs1 rs2) w ci1 ->       [OutTerm (Append rs1 rs2) ci1 co] ->+      [PendingWrite (Append rs1 rs2) ci1] ->       OutTerm rs1 ci1 mid ->       s2 ->       Edge (HsPred rs2 mid) rs2 mid co s2 ->       PartialPath rs1 rs2 ci1 co s2-    stepPath g u outs o _s2 e2 = case e2 of+    stepPath g u outs env o _s2 e2 = case e2 of       Edge {update = u2} ->-        PartialPath-          (PAnd g (substPred @rs1 @rs2 (guard e2) o))-          (UCombine u (substUpdate @rs1 @rs2 u2 o))-          (outs ++ map (\o2 -> substOut @rs1 @rs2 o2 o) (output e2))-          (target e2)+        let stepGuard =+              applyEnvPred env (substPred @rs1 @rs2 (guard e2) o)+            stepUpdate =+              applyEnvUpdate env (substUpdate @rs1 @rs2 u2 o)+            stepOutputs =+              map+                (applyEnvOut env . (\o2 -> substOut @rs1 @rs2 o2 o))+                (output e2)+            nextEnv = pendingWrites stepUpdate ++ env+         in PartialPath+              (PAnd g stepGuard)+              (UCombine u stepUpdate)+              (outs ++ stepOutputs)+              nextEnv+              (target e2)      -- \| Convert a fully-expanded path to a composite edge by     -- borrowing t1's @target@ for the composite's target.@@ -1056,7 +1570,7 @@         ci1         co         (Composite s1 s2)-    finalizePath e1 (PartialPath g u outs s2End) =+    finalizePath e1 (PartialPath g u outs _env s2End) =       Edge         { guard = g,           update = u,@@ -1096,14 +1610,12 @@ --     wrapping; symmetric for @Right@. --   * Hidden-input check: each side's per-edge check inherits via --     the lifters (which preserve 'TInpCtorField' slot reads).---   * Symbolic single-valuedness: at any @Composite s1 s2@, the t1---     edges' guards (which require @Left _@ via 'leftInCtor') and---     t2 edges' guards (which require @Right _@ via 'rightInCtor')---     are pairwise mutually exclusive. Within each arm,+--   * Symbolic single-valuedness: every lifted t1 guard is conjoined+--     with 'PLeftArm' and every lifted t2 guard with 'PRightArm'. The+--     concrete and symbolic interpretations make these independent arm+--     predicates disjoint even when the original guard was 'PTop'. Within each arm, --     single-valuedness reduces to the underlying sub-aggregate's---     check at the relevant sub-vertex. **No cross-transducer---     mutual-exclusion check is needed** — the @Either@ arms make---     it vacuous.+--     check at the relevant sub-vertex. -- -- See 'docs/research/composition-combinators-design.md' under -- "Combinators beyond `compose`" → "`alternative` — admitted" for@@ -1156,8 +1668,11 @@       Edge {update = u1} ->         Edge           { guard =-              liftLPredAlt @(Append rs1 rs2) @ci1 @ci2-                (weakenLPred @rs1 @rs2 (guard e1)),+              PAnd+                PLeftArm+                ( liftLPredAlt @(Append rs1 rs2) @ci1 @ci2+                    (weakenLPred @rs1 @rs2 (guard e1))+                ),             update =               liftLUpdateAlt @(Append rs1 rs2) @_ @ci1 @ci2                 (weakenLUpdate @rs1 @rs2 u1),@@ -1183,8 +1698,11 @@       Edge {update = u2} ->         Edge           { guard =-              liftRPredAlt @(Append rs1 rs2) @ci1 @ci2-                (weakenRPred @rs1 @rs2 (guard e2)),+              PAnd+                PRightArm+                ( liftRPredAlt @(Append rs1 rs2) @ci1 @ci2+                    (weakenRPred @rs1 @rs2 (guard e2))+                ),             update =               liftRUpdateAlt @(Append rs1 rs2) @_ @ci1 @ci2                 (weakenRUpdate @rs1 @rs2 u2),@@ -1199,8 +1717,8 @@  -- * feedback1 ------------------------------------------------------------ --- | Single-step feedback combinator. Models one round of an--- aggregate ↔ stateless-policy reaction: the aggregate consumes an+-- | Single-step two-copy cascade. It runs one round of an+-- aggregate-shaped transducer ↔ policy reaction: the first copy consumes an -- external command, the policy observes the aggregate's emitted -- event and emits a follow-up command, and the aggregate consumes -- that follow-up. The composite emits the aggregate's *second*@@ -1213,17 +1731,31 @@ --   * The outer @compose t _@ feeds t's first event into that --     inner pipeline. ----- The composite vertex is @Composite s1 (Composite s2 s1)@ —+-- This is /not shared-state aggregate feedback/. The composite vertex is+-- @Composite s1 (Composite s2 s1)@ — -- "outer t state, then (policy state, inner t state)". Even though -- the inner @s1@ is the same Haskell type as the outer, it occupies--- a distinct dimension of the composite vertex tuple, so+-- a distinct state dimension and (when admitted by the constraints) a+-- distinct register segment. The policy-produced command therefore updates+-- the inner copy, not the aggregate state that handled the external command.+-- The regression in @Keiki.CompositionFeedback1Spec@ distinguishes this from+-- shared feedback with a toggle. Consequently there is deliberately no+-- @feedback1Checked@: alignment cannot make two copies share state.+-- -- 'Keiki.Symbolic.isSingleValuedSym''s per-vertex enumeration walks+-- 'Keiki.Symbolic.isSingleValuedSym''s per-vertex enumeration walks -- all @|s1| * |s2| * |s1|@ combinations independently. -- -- Multi-round patterns are expressed by nesting: -- --     twoRounds = feedback1 (feedback1 t f) f --+-- This nested form requires both @rs1 ~ '[]@ and @rs2 ~ '[]@: the+-- outer call's slot-disjointness check ranges over the first result's+-- @Append rs1 (Append rs2 rs1)@ register file, so the policy must be+-- stateless as well as the aggregate. A single unnested call only forces+-- @rs1 ~ '[]@.+-- -- The pure-core boundary holds because there is no loop — the -- cascade runs exactly once per external command. --@@ -1242,10 +1774,10 @@ -- A "shared-state" variant — where the second t reads/writes the -- first t's registers via a custom edge construction — is -- documented as a future extension and is not in scope for MP-8.--- The "stateless policy" recommendation (single vertex, empty--- @rs2@) is convention rather than enforced; if violated, the--- composite still typechecks but the single-step semantics may--- not be preserved.+-- For one call, the "stateless policy" recommendation (single vertex,+-- empty @rs2@) is convention rather than enforced. Nesting the result in+-- another 'feedback1' does enforce empty @rs2@ through the next call's+-- 'Disjoint' constraint, as described above. -- -- == Future extensions --
src/Keiki/Core.hs view
@@ -2,1836 +2,2683 @@ -- sees it as unused (the body is @UCombine@) and would otherwise warn. -- Same reasoning for any future helpers that re-export the constraint -- as a typed witness.-{-# OPTIONS_GHC -Wno-redundant-constraints #-}---- | The pure core of keiki: the symbolic-register transducer.------ This module is the v1 prototype of the design pinned by--- @docs/research/dsl-shape-for-symbolic-register.md@ (the DSL note),--- @docs/research/effects-boundary.md@ (the boundary note), and--- @docs/research/synthesis-c-foundation-b-presentation-with-worked-examples.md@--- (the working baseline). See those notes for the rationale behind every--- shape declared here.------ All v1 escape hatches were retired by MasterPlan 6 (see the--- Outcomes section of--- @docs/masterplans/6-retire-remaining-v1-escape-hatches-in-pure-core-ofn-pmatchc-unsafecombine-static-check.md@):--- @TInpField@ / @OPack@'s hand-written inverse (MP-2 EP-1), 'OFn' /--- 'mkOut' (MP-6 EP-16), 'PMatchC' / 'matchCmd' (MP-6 EP-17), and--- 'unsafeCombine' (MP-6 EP-18, replaced by the static 'Disjoint'--- check on 'combine').------ == Guard-authoring operators (EP-45)------ Predicates and term arithmetic can be written with infix operators--- that mirror their Prelude counterparts:------   * Relational (build 'HsPred', @infix 4@): '.<' '.<=' '.>' '.>='---     '.==' './=' — each an alias for 'PCmp'/'PEq' at a fixed relation.---   * Logical (combine 'HsPred'): '.&&' (@infixr 3@, 'PAnd'),---     '.||' (@infixr 2@, 'POr'), 'pnot' ('PNot').---   * Arithmetic (build 'Term', mirror @+@\/@-@\/@*@): '.+' '.-' '.*' —---     aliases for 'tadd'\/'tsub'\/'tmul'.------ The verbose carrier signatures have synonyms: 'Pred' @rs ci@ for--- @'HsPred' rs ci@, 'Guarded' @rs s ci co@ for--- @'SymTransducer' ('HsPred' rs ci) rs s ci co@ (and--- 'Keiki.Symbolic.SymGuarded' for the SBV-backed carrier).------ Keep spaces around the operators (@lit a .* lit b@); a dot touching an--- identifier (@x.y@) is OverloadedRecordDot field access. If you import--- "Data.SBV" alongside this module, import it qualified — SBV exports--- the same operator names.-module Keiki.Core-  ( -- * Slots and the register file-    Slot,-    RegFile (..),-    Index (..),-    (!),--    -- * Index resolution from labels-    HasIndex (..),--    -- * Term language-    Term (..),-    NumOp (..),--    -- * Input-side structural constructor (v2)-    InCtor (..),-    AssembleRegFile,-    KnownSlotNames (..),--    -- * Slot-name machinery (re-exported from "Keiki.Internal.Slots")-    IndexN (..),-    HasIndexN (..),-    Disjoint,-    Concat,-    Names,--    -- * Update language-    Update (..),-    combine,--    -- * Output term language-    WireCtor (..),-    OutFields (..),-    (*:),-    oNil,-    OutTerm (..),--    -- * Predicate carrier (v1 first-class AST)-    HsPred (..),-    Pred,-    Cmp (..),--    -- * Effective Boolean algebra-    BoolAlg (..),-    Sat (..),--    -- * Edges and the transducer-    Edge (..),-    SymTransducer (..),-    Guarded,-    applyEdgeUpdate,-    edgeReadsInput,--    -- * Helpers (the user-facing DSL surface)-    matchInCtor,-    proj,-    inpCtor,-    lit,-    tadd,-    tsub,-    tmul,-    (.==),-    (.<),-    (.<=),-    (.>),-    (.>=),-    (./=),-    (.&&),-    (.||),-    pnot,-    (.+),-    (.-),-    (.*),-    pack,--    -- * Evaluators-    evalTerm,-    evalOut,-    evalPred,-    runUpdate,-    delta,-    omega,--    -- * Pure-layer entry points (effects-boundary note)-    step,-    stepEither,-    StepFailure (..),-    EdgeRef (..),-    RejectedEdgeSummary (..),-    MatchedEdgeSummary (..),-    reconstitute,-    applyEvent,-    applyEventStreaming,-    applyEvents,--    -- * Streaming-replay state wrapper (EP-19 M3)-    InFlight (..),--    -- * Build-time analyses-    solveOutput,-    HiddenInputWarning (..),-    checkHiddenInputs,--    -- * Build-time validation umbrella (EP-56)-    TransducerValidationWarning (..),-    ValidationOptions (..),-    defaultValidationOptions,-    validateTransducer,-    hiddenInputWarnings,-    opaqueGuardWarnings,-    DeterminismWarning (..),-    checkTransitionDeterminism,-    checkTransitionDeterminismPure,-    DeadEdgeOptions (..),-    defaultDeadEdgeOptions,-    DeadEdgeWarning (..),-    checkDeadEdges,--    -- * Internals exposed for testing-    termReadsInput,-    updateReadsInput,-    outFieldsHaveInpCtorField,-    detectMissingInCtorFields,-    MissingInCtorFields (..),-  )-where--import Data.Kind (Type)-import Data.List (nub, (\\))-import Data.Proxy (Proxy (..))-import Data.Set qualified as Set-import Data.Typeable (Typeable)-import GHC.OverloadedLabels (IsLabel (..))-import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)-import Keiki.Internal.Slots-  ( Concat,-    Disjoint,-    HasIndexN (..),-    IndexN (..),-    Names,-  )---- | A register slot is a label paired with the type of its value.-type Slot = (Symbol, Type)---- * Register file --------------------------------------------------------------- | A typed heterogeneous register tuple indexed by a list of 'Slot's.------ The slot-value field is intentionally lazy: 'Keiki.Generics.emptyRegFile'--- seeds each slot with a deferred @error "uninit: \<slot\>"@ thunk so--- that reading an unwritten slot fails loudly with a targeted message--- instead of returning a silent bottom. Strictness for *written*--- slots is enforced on the write path ('setSlotN') instead — see--- EP-23's Surprises entry for the long-running-service rationale.-data RegFile (rs :: [Slot]) where-  RNil :: RegFile '[]-  RCons ::-    (KnownSymbol s) =>-    Proxy s -> r -> RegFile rs -> RegFile ('(s, r) ': rs)---- | A type-safe pointer into a 'RegFile'. 'ZIdx' picks the head;--- 'SIdx' skips one slot.-data Index (rs :: [Slot]) (r :: Type) where-  ZIdx :: (KnownSymbol s) => Index ('(s, r) ': rs) r-  SIdx :: Index rs r -> Index ('(s', r') ': rs) r---- | Runtime register lookup. Matching on 'Index' first lets GHC's GADT--- pattern checker see that 'RNil' is unreachable — 'ZIdx' and 'SIdx'--- both refine @rs@ to @'(_,_) ': _@.-(!) :: RegFile rs -> Index rs r -> r-regs ! ZIdx = case regs of RCons _ x _ -> x-regs ! SIdx i = case regs of RCons _ _ rest -> rest ! i--infixl 9 !---- * IsLabel / HasIndex --------------------------------------------------------- | Resolve a label @s@ against a slot list @rs@ to an 'Index' for the--- value at that slot. The functional dependency @s rs -> r@ ensures that--- a label uniquely determines the slot's type.-class-  HasIndex (s :: Symbol) (rs :: [Slot]) (r :: Type)-    | s rs -> r-  where-  indexOf :: Index rs r--instance-  {-# OVERLAPPING #-}-  (KnownSymbol s) =>-  HasIndex s ('(s, r) ': rs) r-  where-  indexOf = ZIdx--instance-  {-# OVERLAPPABLE #-}-  forall s s' r r' rs.-  (HasIndex s rs r) =>-  HasIndex s ('(s', r') ': rs) r-  where-  indexOf = SIdx (indexOf @s @rs @r)--instance-  forall s rs r.-  (HasIndex s rs r) =>-  IsLabel s (Index rs r)-  where-  fromLabel = indexOf @s @rs @r---- | Resolve a label directly to a 'Term' that reads the named register.--- This instance lets call sites write @#name@ in any 'Term'-typed--- context (the arguments of 'requireEq', the elements of 'OutFields',--- etc.) without the @proj (#name :: Index Regs T)@ annotation that--- 'IsLabel s (Index rs r)' alone would require.------ The two 'IsLabel' instances ('Index' and 'Term') coexist because GHC--- dispatches by the expected result type: a context expecting an--- 'Index' (e.g. 'inpFoo'\'s argument) selects the 'Index' instance; a--- context expecting a 'Term' (e.g. 'requireEq'\'s arguments) selects--- this one.-instance-  forall s rs ci ifs r.-  (HasIndex s rs r) =>-  IsLabel s (Term rs ci ifs r)-  where-  fromLabel = TReg (indexOf @s @rs @r)---- The @IsLabel s (IndexN s rs r)@ instance lives next to 'IndexN' in--- "Keiki.Internal.Slots" so the orphan check is satisfied.---- * Term language -------------------------------------------------------------- | A numeric operation carried by 'TArith'. @OpAdd@\/@OpSub@\/@OpMul@--- are @+@\/@-@\/@*@ respectively. Kept as a single tag (rather than--- three 'Term' constructors) so each total 'Term' walker switches on--- one value; the three directions are recovered by the smart--- constructors 'tadd'\/'tsub'\/'tmul'.-data NumOp = OpAdd | OpSub | OpMul-  deriving stock (Eq, Show)---- | A pure expression over the register file and the input symbol,--- yielding a value of type @r@.------ The @ifs :: [Slot]@ parameter is the /input field schema/ this term--- may project from: it is pinned by 'TInpCtorField' (whose 'Index' is--- into @ifs@) and left free by terms that do not read an input field--- ('TLit', 'TReg'). Threading @ifs@ through the AST is what lets an--- 'OutFields' (and hence an 'OPack') guarantee /by construction/ that--- every top-level input projection reads the same constructor schema as--- the 'OPack''s 'InCtor' — so 'solveOutput' recovers a command field--- with no @unsafeCoerce@. Terms that do not appear in an invertible--- output position ('Update' right-hand sides, 'HsPred' operands)--- existentially hide @ifs@, so it never leaks into the 'Edge' /--- 'SymTransducer' surface. See @docs/research/tinpproj-design.md@.-data Term (rs :: [Slot]) (ci :: Type) (ifs :: [Slot]) (r :: Type) where-  TLit :: r -> Term rs ci ifs r-  TReg :: Index rs r -> Term rs ci ifs r-  -- | Structural input projection: read field @ix@ of the input-  --     constructor described by @ic@. The 'InCtor' value names the-  --     expected constructor and supplies the round-trip-  --     ('icMatch'/'icBuild') so that 'solveOutput' can mechanically-  --     recover @ci@ from an observed output. Pins the term's @ifs@ to the-  --     constructor's field schema. See @docs/research/tinpproj-design.md@.-  TInpCtorField :: InCtor ci ifs -> Index ifs r -> Term rs ci ifs r-  TApp1 ::-    (a -> r) ->-    Term rs ci ifs a ->-    Term rs ci ifs r-  TApp2 ::-    (a -> b -> r) ->-    Term rs ci ifs a ->-    Term rs ci ifs b ->-    Term rs ci ifs r-  -- | Structural arithmetic over a numeric operand type. Unlike the-  --     opaque 'TApp1'\/'TApp2' escape hatches, the SBV translator reads-  --     'TArith' for real (on a 'Keiki.Symbolic.discoverSymNum' hit), so a-  --     guard over a /computed/ value — a weighted sum, a derived cap — is-  --     visible to the solver. The 'Num' constraint prevents constructing-  --     arithmetic at non-numeric operand types; 'Typeable' lets the SBV-  --     translator dispatch on @r@. Build with 'tadd'\/'tsub'\/'tmul'.-  TArith ::-    (Num r, Typeable r) =>-    NumOp ->-    Term rs ci ifs r ->-    Term rs ci ifs r ->-    Term rs ci ifs r---- | Per-constructor input projection. An 'InCtor' value names one--- constructor of the input symbol type @ci@ and pins the round-trip--- between that constructor's payload and a typed register file--- @'RegFile' ifs@. The slot list @ifs@ is the field schema for the--- constructor; together with 'Index' it lets call sites read fields--- via 'OverloadedLabels' (for example @inpStart #email@).------ 'icMatch' must return 'Just' iff @ci@ is the named constructor.--- 'icBuild' is its left inverse: @icMatch (icBuild rf) == Just rf@ for--- every well-formed @rf@.------ The constraints 'AssembleRegFile' and 'KnownSlotNames' on the data--- constructor mean that any code holding an 'InCtor' can both--- mechanically rebuild a 'RegFile' from a bag of '(Index, value)' pairs--- and recover the slot names of @ifs@ at run time. The instances are--- automatic for any concrete slot list, so users do not write any--- additional code.------ See @docs/research/tinpproj-design.md@ for the design rationale and--- the inversion algorithm that walks 'OutFields' gathering these--- per-field reads.-data InCtor ci (ifs :: [Slot]) where-  InCtor ::-    (AssembleRegFile ifs, KnownSlotNames ifs) =>-    { icName :: String,-      icMatch :: ci -> Maybe (RegFile ifs),-      icBuild :: RegFile ifs -> ci-    } ->-    InCtor ci ifs---- * Slot-list helper classes (v2 inversion machinery) ------------------------- | Recover the slot names of an @ifs :: [Slot]@ at run time. Used to--- print precise hidden-input warnings.-class KnownSlotNames (rs :: [Slot]) where-  slotNames :: [String]--instance KnownSlotNames '[] where-  slotNames = []--instance-  (KnownSymbol s, KnownSlotNames rs) =>-  KnownSlotNames ('(s, r) ': rs)-  where-  slotNames = symbolVal (Proxy @s) : slotNames @rs---- | An (Index, value) pair indexed by an InCtor's slot list. Using a--- GADT existential lets us bag entries with different element types--- under one slot list and unpack them safely via pattern matching on--- the carried 'Index'.-data ByIndex (ifs :: [Slot]) where-  ByIndex :: Index ifs r -> r -> ByIndex ifs---- | Class to assemble a 'RegFile' from a bag of '(Index, value)' pairs.--- 'assemble' returns 'Just' iff every slot of @ifs@ is covered by--- exactly one entry of the bag (extra entries beyond what slots--- demand are ignored as long as the per-slot lookups succeed in--- order).-class AssembleRegFile (ifs :: [Slot]) where-  assemble :: [ByIndex ifs] -> Maybe (RegFile ifs)--instance AssembleRegFile '[] where-  assemble _ = Just RNil--instance-  (KnownSymbol s, AssembleRegFile rs) =>-  AssembleRegFile ('(s, r) ': rs)-  where-  assemble entries = do-    v <- findHead entries-    rest <- assemble (popHead entries)-    pure (RCons (Proxy @s) v rest)-    where-      findHead :: [ByIndex ('(s, r) ': rs)] -> Maybe r-      findHead [] = Nothing-      findHead (ByIndex ZIdx v : _) = Just v-      findHead (_ : rest) = findHead rest--      popHead :: [ByIndex ('(s, r) ': rs)] -> [ByIndex rs]-      popHead [] = []-      popHead (ByIndex ZIdx _ : rest) = popHead rest-      popHead (ByIndex (SIdx i) v : rest) = ByIndex i v : popHead rest---- * Update language ------------------------------------------------------------ | The copyless update language. The @(w :: [Symbol])@ index--- records the set of slot names this update writes; the smart--- constructor 'combine' demands @'Disjoint' w1 w2@ to combine two--- updates, so "each register is written at most once per edge--- update" becomes a type-level invariant rather than a runtime check.------ The 'UCombine' raw constructor is *not* constrained by 'Disjoint':--- the invariant is enforced at the smart-constructor introduction--- point ('combine'). This keeps internal pattern-matches in--- "Keiki.Composition" (which reconstruct 'UCombine' values during--- weakening / substitution) cheap. EP-18 M8 retired the v1--- 'unsafeCombine' escape hatch; aggregate authors use 'combine'--- exclusively.-data Update (rs :: [Slot]) (w :: [Symbol]) (ci :: Type) where-  UKeep :: Update rs '[] ci-  -- The right-hand-side 'Term''s input field schema @ifs@ is-  -- existentially hidden: updates are never inverted, so @ifs@ need not-  -- escape into the 'Update' kind (keeping 'Edge' / 'SymTransducer'-  -- unchanged).-  USet ::-    (KnownSymbol s) =>-    IndexN s rs r -> Term rs ci ifs r -> Update rs '[s] ci-  UCombine ::-    Update rs w1 ci ->-    Update rs w2 ci ->-    Update rs (Concat w1 w2) ci---- | Smart constructor for 'UCombine'. The @'Disjoint' w1 w2@--- constraint statically enforces that the two halves write to--- disjoint slot-name sets; an aggregate that writes the same slot--- twice (e.g. @'USet' #email t1 \`combine\` 'USet' #email t2@) is--- rejected at compile time with a 'GHC.TypeError.TypeError' naming--- the offending slot.-combine ::-  (Disjoint w1 w2) =>-  Update rs w1 ci ->-  Update rs w2 ci ->-  Update rs (Concat w1 w2) ci-combine = UCombine---- * Output term language ------------------------------------------------------- | A wire-type tag for one constructor of the user's output sum @co@.--- The functions let 'solveOutput' pattern-match an observed @co@ and--- 'evalOut' rebuild a @co@ from its fields.-data WireCtor co fields = WireCtor-  { wcName :: String,-    wcMatch :: co -> Maybe fields,-    wcBuild :: fields -> co-  }---- | An HList of 'Term's, one per field of the wire constructor. The--- field-tuple type @fs@ is built up nested-pair style so that--- 'solveOutput' can walk the HList structurally.------ The @ifs :: [Slot]@ parameter is the shared input field schema of--- every 'Term' in the list (see 'Term'). 'OPack' ties it to the--- 'OPack''s 'InCtor', so a top-level 'TInpCtorField' inside an--- 'OutFields' is statically an 'Index' into the 'OPack''s constructor--- schema — 'gatherInpEntries' recovers it with no coercion.-data OutFields rs ci ifs fs where-  OFNil :: OutFields rs ci ifs ()-  OFCons ::-    Term rs ci ifs f ->-    OutFields rs ci ifs fs ->-    OutFields rs ci ifs (f, fs)---- | Right-associative HList constructor synonym for 'OFCons'. Lets--- 'OutFields' literals read top-to-bottom in the wire ctor's field--- order:------ > d.recipient *: d.subject *: d.at *: oNil------ Identical AST: @t1 *: t2 *: oNil@ produces the same 'OutFields'--- value as @OFCons t1 (OFCons t2 OFNil)@. Available at the AST--- layer (here) so authors who skip the builder can use it; also--- re-exported by "Keiki.Builder" for builder-form call sites.-(*:) :: Term rs ci ifs f -> OutFields rs ci ifs fs -> OutFields rs ci ifs (f, fs)-(*:) = OFCons--infixr 5 *:---- | The empty 'OutFields' HList. Synonym for 'OFNil'.-oNil :: OutFields rs ci ifs ()-oNil = OFNil---- | A pure expression yielding an output value @co@.-data OutTerm (rs :: [Slot]) (ci :: Type) (co :: Type) where-  -- | Structural pack: tagged by an input constructor (which the edge-  --     consumes) and an output wire constructor (which the edge produces),-  --     with one 'Term' per field of the wire constructor. 'solveOutput'-  --     walks the structural 'OutFields', gathering '(Index, value)' pairs-  --     against the named 'InCtor', and reconstructs the input by calling-  --     'icBuild' on the assembled register file. Empty-payload input-  --     constructors (the 'InCtor's slot list is @\'[]@) recover trivially-  --     as @icBuild ic RNil@.-  OPack ::-    InCtor ci ifs ->-    WireCtor co fields ->-    OutFields rs ci ifs fields ->-    OutTerm rs ci co---- * Predicate carrier ---------------------------------------------------------- | The predicate AST. Carries enough structure to evaluate guards and--- to translate to SMT through the SBV-backed 'BoolAlg' instance in--- "Keiki.Symbolic" (added in EP-2 of MasterPlan 2).-data HsPred (rs :: [Slot]) (ci :: Type) where-  PTop :: HsPred rs ci-  PBot :: HsPred rs ci-  PAnd :: HsPred rs ci -> HsPred rs ci -> HsPred rs ci-  POr :: HsPred rs ci -> HsPred rs ci -> HsPred rs ci-  PNot :: HsPred rs ci -> HsPred rs ci-  PEq ::-    (Eq r, Typeable r) =>-    Term rs ci ifs1 r -> Term rs ci ifs2 r -> HsPred rs ci-  -- | Structural input-constructor guard: @True@ iff the input symbol-  --     is the constructor named by the carried 'InCtor'. The SBV-backed-  --     'BoolAlg' instance recognises constructor mutual exclusion-  --     symbolically through this constructor. See-  --     @docs/research/sbv-boolalg-design.md@.-  PInCtor :: InCtor ci ifs -> HsPred rs ci-  -- | Ordering guard: compares two 'Term's of the same orderable type-  --     with the relation named by 'Cmp'. @PCmp CmpGe a b@ means @a >= b@,-  --     and so on. Unlike a threshold written through 'TApp1'\/'TApp2'-  --     (which is opaque to the solver), 'PCmp' is /structural/: the-  --     SBV-backed translator in "Keiki.Symbolic" emits a real symbolic-  --     comparison (@.<@, @.<=@, @.>@, @.>=@) whenever the operand type's-  --     'Keiki.Symbolic.SymRep' is symbolically orderable (see-  --     'Keiki.Symbolic.discoverSymOrd'); otherwise it falls back to a-  --     fresh opaque 'SBool', exactly as 'PEq' does for non-'Sym' operands.-  --     Equality is intentionally left to 'PEq' — 'Cmp' has no "equal"-  --     case. Added by EP-41.-  PCmp ::-    (Ord r, Typeable r) =>-    Cmp -> Term rs ci ifs1 r -> Term rs ci ifs2 r -> HsPred rs ci---- | A four-way ordering relation carried by 'PCmp'. @Lt@\/@Le@\/@Gt@\/--- @Ge@ are @<@\/@<=@\/@>@\/@>=@ respectively. Kept as a single tag--- (rather than four 'HsPred' constructors) so the evaluator and the--- SBV translator each switch on one value; the four directions are--- recovered by the builder conveniences--- 'Keiki.Builder.requireLt'\/'requireLe'\/'requireGt'\/'requireGe'.-data Cmp = CmpLt | CmpLe | CmpGt | CmpGe-  deriving stock (Eq, Show)---- * Effective Boolean algebra -------------------------------------------------- | An effective Boolean algebra over @a@-typed witnesses, used as the--- guard carrier of edges. Witness /extraction/ ('sat') is a separate,--- stronger capability — see 'Sat'.-class BoolAlg phi a | phi -> a where-  top :: phi-  bot :: phi-  conj :: phi -> phi -> phi-  disj :: phi -> phi -> phi-  neg :: phi -> phi-  models :: phi -> a -> Bool-  isBot :: phi -> Bool---- | A 'BoolAlg' whose witnesses can be /extracted/ from a satisfiable--- predicate: @'sat' phi@ returns 'Just' a value satisfying @phi@, or--- 'Nothing' when @phi@ is unsatisfiable.------ Split out of 'BoolAlg' by EP-44 (MasterPlan 12). Witness--- reconstruction needs carrier-specific evidence — for the SBV-backed--- 'Keiki.Symbolic.SymPred' carrier, @ExtractRegFile rs@ (to rebuild the--- register file from the solver model) and @KnownInCtors ci@ (to rebuild--- the command) — that the algebra's build/decide methods do not. Keeping--- 'sat' in its own class means the witness-free analyses--- ('Keiki.Symbolic.isSingleValuedSym', which uses only 'isBot'/'conj')--- carry no extraction constraints, so they keep type-checking on--- register-file-existential carriers (e.g. 'Keiki.Profunctor.SomeSymTransducer')--- and on composition-produced @ci@ types ('Either', tuples) that have no--- 'KnownInCtors'. See @docs/research/sbv-boolalg-design.md@.-class (BoolAlg phi a) => Sat phi a where-  sat :: phi -> Maybe a--instance BoolAlg (HsPred rs ci) (RegFile rs, ci) where-  top = PTop-  bot = PBot-  conj p q = PAnd p q-  disj p q = POr p q-  neg p = PNot p-  models p (regs, ci) = evalPred p regs ci-  isBot PBot = True-  isBot _ = False---- | The v1 syntactic carrier has no solver, hence no extractable--- witness; 'sat' is always 'Nothing'. The precise witnesses come from--- the SBV-backed @Sat (SymPred …)@ instance in "Keiki.Symbolic".-instance Sat (HsPred rs ci) (RegFile rs, ci) where-  sat _ = Nothing---- * Edges and the transducer --------------------------------------------------- | A single transition. The 'output' is a list of 'OutTerm's:--- @[]@ is the ε-edge (no observable emission), @[o]@ is the letter--- edge (one event, identical to today's @'Just' o@), @[o1, o2, ...]@--- is the multi-event edge — one transition emits N events in--- declaration order. See @docs/research/gsm-widening-design.md@.------ The @(w :: [Symbol])@ index on 'update' (the slot-name set the--- update writes) is *existentially* quantified at the 'Edge' record--- — different edges out of the same vertex write different slot--- sets, but the homogeneous list @[Edge phi rs ci co s]@ in--- 'edgesOut' demands a single @Edge@ type. The existential preserves--- the static disjointness check at the *introduction* point of any--- 'Update' value (via 'combine') without polluting the @Edge@'s--- public type with a per-edge @w@ parameter.-data Edge phi rs ci co s where-  Edge ::-    { guard :: phi,-      update :: Update rs w ci,-      output :: [OutTerm rs ci co],-      target :: s-    } ->-    Edge phi rs ci co s---- | The single source of truth: a finite control graph plus a register--- file evolved by edges' 'update' terms.-data SymTransducer phi rs s ci co = SymTransducer-  { edgesOut :: s -> [Edge phi rs ci co s],-    initial :: s,-    initialRegs :: RegFile rs,-    isFinal :: s -> Bool-  }---- | Readable alias for the v1 predicate carrier:--- @'Pred' rs ci@ is exactly @'HsPred' rs ci@.-type Pred rs ci = HsPred rs ci---- | A 'SymTransducer' whose guard carrier is the v1 'HsPred'. Collapses--- the @'SymTransducer' ('HsPred' rs ci) rs s ci co@ signature — which--- otherwise repeats @rs@ and @ci@ — into @'Guarded' rs s ci co@.-type Guarded rs s ci co = SymTransducer (HsPred rs ci) rs s ci co---- | Apply an edge's update to the register file. The 'Edge''s--- existentially-quantified @w@ index makes @'update' e@ unusable as--- a function (GHC rejects with "escaped type variables"); this--- helper hides the existential by pattern-matching internally.-applyEdgeUpdate ::-  Edge phi rs ci co s -> RegFile rs -> ci -> RegFile rs-applyEdgeUpdate Edge {update = u} regs ci = runUpdate u regs ci---- | Does an edge's update read the input symbol via 'TInpCtorField'?--- Existential-hiding companion to 'updateReadsInput'.-edgeReadsInput :: Edge phi rs ci co s -> Bool-edgeReadsInput Edge {update = u} = updateReadsInput u---- * Helpers (DSL surface) ------------------------------------------------------ | Structural input-constructor guard: @True@ iff the input symbol--- is the constructor named by the supplied 'InCtor'. The SBV-backed--- 'BoolAlg' instance can decide constructor-mutual-exclusion--- symbolically through this guard. The semantics is--- @evalPred (matchInCtor ic) regs ci == isJust (icMatch ic ci)@.-matchInCtor :: InCtor ci ifs -> HsPred rs ci-matchInCtor = PInCtor---- | Read a register slot into a 'Term'.-proj :: Index rs r -> Term rs ci ifs r-proj = TReg---- | Structural input projection: read field @ix@ of the input--- constructor described by @ic@. The result 'Term''s @ifs@ is the--- constructor's field schema, so an 'OutFields' built from these is--- statically tied to the 'OPack''s 'InCtor'.-inpCtor :: InCtor ci ifs -> Index ifs r -> Term rs ci ifs r-inpCtor = TInpCtorField---- | A constant 'Term'.-lit :: r -> Term rs ci ifs r-lit = TLit---- | Structural arithmetic smart constructors. @tadd@\/@tsub@\/@tmul@--- build a 'TArith' over @+@\/@-@\/@*@. The operand type must be numeric--- ('Num') and 'Typeable'; the SBV translator reads them structurally--- (see 'Keiki.Symbolic.discoverSymNum'), unlike the opaque 'TApp'--- escape hatches.-tadd,-  tsub,-  tmul ::-    (Num r, Typeable r) =>-    Term rs ci ifs r -> Term rs ci ifs r -> Term rs ci ifs r-tadd = TArith OpAdd-tsub = TArith OpSub-tmul = TArith OpMul---- | Equality predicate sugar.-(.==) :: (Eq r, Typeable r) => Term rs ci ifs1 r -> Term rs ci ifs2 r -> HsPred rs ci-(.==) = PEq--infix 4 .==---- * Predicate & term operators (readable guard DSL) -------------------------- | Ordering-guard operators. Each is an alias for 'PCmp' at a fixed--- 'Cmp': @a .>= b@ is @'PCmp' 'CmpGe' a b@ (i.e. @a >= b@); @a .< b@ is--- @'PCmp' 'CmpLt' a b@; and so on. Same fixity as '(.==)' (@infix 4@):--- relational operators do not chain, sit below the arithmetic operators--- ('.+'/'.-'/'.*'), and above the logical ones ('.&&'/'.||').-(.<),-  (.<=),-  (.>),-  (.>=) ::-    (Ord r, Typeable r) => Term rs ci ifs1 r -> Term rs ci ifs2 r -> HsPred rs ci-(.<) = PCmp CmpLt-(.<=) = PCmp CmpLe-(.>) = PCmp CmpGt-(.>=) = PCmp CmpGe--infix 4 .<, .<=, .>, .>=---- | Inequality guard. @a ./= b@ is @'pnot' (a '.==' b)@, i.e.--- @'PNot' ('PEq' a b)@. Mirrors 'Prelude.(/=)' against the existing--- '(.==)'.-(./=) :: (Eq r, Typeable r) => Term rs ci ifs1 r -> Term rs ci ifs2 r -> HsPred rs ci-a ./= b = PNot (PEq a b)--infix 4 ./=---- | Conjunction / disjunction of predicates. Aliases for 'PAnd' / 'POr',--- mirroring 'Prelude.(&&)' / 'Prelude.(||)' in fixity (@infixr 3@ /--- @infixr 2@), so @p .&& q .|| r@ parses as @(p .&& q) .|| r@.-(.&&), (.||) :: HsPred rs ci -> HsPred rs ci -> HsPred rs ci-(.&&) = PAnd-(.||) = POr--infixr 3 .&&--infixr 2 .||---- | Predicate negation. Alias for 'PNot'. ('Keiki.Core.BoolAlg' also--- exposes 'neg', which is this same operation lifted through the class;--- 'pnot' is the direct AST alias for hand-written guards.)-pnot :: HsPred rs ci -> HsPred rs ci-pnot = PNot---- | Structural arithmetic operators on 'Term's. Aliases for--- 'tadd' / 'tsub' / 'tmul', mirroring 'Prelude.(+)' / '(-)' / '(*)' in--- fixity (@infixl 6@ / @infixl 6@ / @infixl 7@). Because they build the--- structural 'TArith' node (not an opaque 'TApp'), arithmetic written--- with them is visible to the SBV translator in "Keiki.Symbolic".-(.+),-  (.-),-  (.*) ::-    (Num r, Typeable r) => Term rs ci ifs r -> Term rs ci ifs r -> Term rs ci ifs r-(.+) = tadd-(.-) = tsub-(.*) = tmul--infixl 6 .+, .---infixl 7 .*---- | Structural-output construction. 'solveOutput' inverts the result--- mechanically by walking 'OutFields' against the named input--- constructor; users no longer supply an inverse function. The--- 'InCtor' first argument names the @ci@ constructor the edge expects;--- it makes recovery work even for edges whose input has no payload--- (e.g. a singleton 'Continue' command).-pack ::-  InCtor ci ifs ->-  WireCtor co fields ->-  OutFields rs ci ifs fields ->-  OutTerm rs ci co-pack = OPack---- * Evaluators ----------------------------------------------------------------- | Evaluate a 'Term' against a register file and an input symbol.-evalTerm :: Term rs ci ifs r -> RegFile rs -> ci -> r-evalTerm (TLit r) _ _ = r-evalTerm (TReg ix) regs _ = regs ! ix-evalTerm (TInpCtorField ic ix) _ ci = case icMatch ic ci of-  Just rf -> rf ! ix-  Nothing -> error ("evalTerm: TInpCtorField guard violation: " ++ icName ic)-evalTerm (TApp1 f t) regs ci = f (evalTerm t regs ci)-evalTerm (TApp2 f a b) regs ci = f (evalTerm a regs ci) (evalTerm b regs ci)-evalTerm (TArith op a b) regs ci =-  applyNumOp op (evalTerm a regs ci) (evalTerm b regs ci)---- | Interpret a 'NumOp' tag as the corresponding numeric operation.--- The 'Num' evidence is supplied by matching the 'TArith' constructor.-applyNumOp :: (Num r) => NumOp -> r -> r -> r-applyNumOp OpAdd = (+)-applyNumOp OpSub = (-)-applyNumOp OpMul = (*)---- | Evaluate an 'OutTerm' against a register file and an input symbol.--- The 'InCtor' on 'OPack' is consulted only by the inverse direction--- ('solveOutput'); evaluation just runs the wire build over the--- evaluated 'OutFields'.-evalOut :: OutTerm rs ci co -> RegFile rs -> ci -> co-evalOut (OPack _ic ctor fields) regs ci =-  wcBuild ctor (evalOutFields fields regs ci)--evalOutFields :: OutFields rs ci ifs fs -> RegFile rs -> ci -> fs-evalOutFields OFNil _ _ = ()-evalOutFields (OFCons t rest) regs ci =-  (evalTerm t regs ci, evalOutFields rest regs ci)---- | Evaluate a predicate to a 'Bool' on the current state.-evalPred :: HsPred rs ci -> RegFile rs -> ci -> Bool-evalPred PTop _ _ = True-evalPred PBot _ _ = False-evalPred (PAnd p q) r c = evalPred p r c && evalPred q r c-evalPred (POr p q) r c = evalPred p r c || evalPred q r c-evalPred (PNot p) r c = not (evalPred p r c)-evalPred (PEq a b) r c = evalTerm a r c == evalTerm b r c-evalPred (PInCtor ic) _ c = case icMatch ic c of-  Just _ -> True-  Nothing -> False-evalPred (PCmp op a b) r c = applyCmp op (evalTerm a r c) (evalTerm b r c)-  where-    applyCmp :: (Ord x) => Cmp -> x -> x -> Bool-    applyCmp CmpLt x y = x < y-    applyCmp CmpLe x y = x <= y-    applyCmp CmpGt x y = x > y-    applyCmp CmpGe x y = x >= y---- | Apply an 'Update' to the register file. 'UCombine' applies left--- then right; the smart 'combine''s 'Disjoint' constraint guarantees--- the two halves write to disjoint slots, so the application order--- does not affect the result.-runUpdate :: Update rs w ci -> RegFile rs -> ci -> RegFile rs-runUpdate UKeep regs _ = regs-runUpdate (USet ix t) regs ci = setSlotN ix (evalTerm t regs ci) regs-runUpdate (UCombine a b) regs ci = runUpdate b (runUpdate a regs ci) ci---- | Pure register-file slot update at a slot-name-tagged 'IndexN'.------ The bang-pattern on @v@ forces the new slot value to WHNF before--- threading it into the rebuilt 'RCons'. Without this, every--- 'runUpdate' / 'step' cycle in a long-running embedder accumulates--- a tower of thunks at the written slot, which is exactly the failure--- mode the @NoThunks (RegFile rs)@ instance ("Keiki.NoThunks") was--- introduced to detect (EP-23). Untouched slots retain whatever--- WHNF status they already had, which preserves--- 'Keiki.Generics.emptyRegFile'\'s targeted @uninit:@ sentinels for--- slots that have never been written.-setSlotN :: IndexN s rs r -> r -> RegFile rs -> RegFile rs-setSlotN IZ !v regs = case regs of RCons p _ rest -> RCons p v rest-setSlotN (IS i) !v regs = case regs of-  RCons p x rest ->-    let !rest' = setSlotN i v rest-     in RCons p x rest'---- | Single-step transition. Returns 'Just (s', regs')' iff exactly one--- outgoing edge has a satisfied guard.-delta ::-  (BoolAlg phi (RegFile rs, ci)) =>-  SymTransducer phi rs s ci co ->-  s ->-  RegFile rs ->-  ci ->-  Maybe (s, RegFile rs)-delta t s regs ci =-  case [ (target e, applyEdgeUpdate e regs ci)-       | e <- edgesOut t s,-         models (guard e) (regs, ci)-       ] of-    [single] -> Just single-    _ -> Nothing---- | Single-step output. Returns the list of events emitted by the--- unique active edge: @[]@ for an ε-edge, @[o]@ for a letter edge,--- @[o1, o2, ...]@ for a multi-event edge. Returns @[]@ if no edge--- (or more than one edge) is active — the caller cannot distinguish--- "no active edge" from "active ε-edge" from this function alone;--- use 'step' or 'delta' if that distinction matters.-omega ::-  (BoolAlg phi (RegFile rs, ci)) =>-  SymTransducer phi rs s ci co ->-  s ->-  RegFile rs ->-  ci ->-  [co]-omega t s regs ci =-  case [ [evalOut o regs ci | o <- output e]-       | e <- edgesOut t s,-         models (guard e) (regs, ci)-       ] of-    [evaluatedOuts] -> evaluatedOuts-    _ -> []---- * Pure-layer entry points ---------------------------------------------------- | One full step of the transducer combining 'delta' and 'omega'.--- Returns 'Nothing' if no edge from the current vertex has a satisfied--- guard. The inner @[co]@ is @[]@ for an ε-edge, @[o]@ for a letter--- edge, @[o1, o2, ...]@ for a multi-event edge.-step ::-  (BoolAlg phi (RegFile rs, ci)) =>-  SymTransducer phi rs s ci co ->-  (s, RegFile rs) ->-  ci ->-  Maybe (s, RegFile rs, [co])-step t (s, regs) ci = case delta t s regs ci of-  Nothing -> Nothing-  Just (s', regs') -> Just (s', regs', omega t s regs ci)---- | A locator for one outgoing edge: the vertex it leaves from and its--- zero-based position in @'edgesOut' t source@. This is the canonical--- edge-identity vocabulary shared with build-time diagnostics (EP-56).-data EdgeRef s = EdgeRef-  { edgeSource :: s,-    edgeIndex :: Int-  }-  deriving stock (Eq, Show)---- | Why one outgoing edge was rejected during a step: its locator, its--- declared target, and whether its guard matched (always 'False' here;--- the field keeps the shape uniform with 'MatchedEdgeSummary' and leaves--- room for richer rejection reasons later). Deliberately carries NO--- register values — diagnostics summarize, they do not dump state.-data RejectedEdgeSummary s = RejectedEdgeSummary-  { rejectedEdge :: EdgeRef s,-    rejectedTarget :: s,-    rejectedGuard :: Bool-  }-  deriving stock (Eq, Show)---- | One outgoing edge whose guard matched during a step: its locator and--- its declared target. Carries NO register values.-data MatchedEdgeSummary s = MatchedEdgeSummary-  { matchedEdge :: EdgeRef s,-    matchedTarget :: s-  }-  deriving stock (Eq, Show)---- | A precise explanation of why a step could not advance.------   * 'NoOutgoingEdges' — the source vertex has no outgoing edges at all.---   * 'NoMatchingEdge'   — there are outgoing edges, but none matched the---     command; carries one 'RejectedEdgeSummary' per edge, in declaration---     order.---   * 'AmbiguousEdges'   — two or more guards matched the same command, a---     runtime witness of a single-valuedness violation (the property---     EP-56's 'checkTransitionDeterminism' proves statically); carries one---     'MatchedEdgeSummary' per matched edge.-data StepFailure s-  = NoOutgoingEdges s-  | NoMatchingEdge s [RejectedEdgeSummary s]-  | AmbiguousEdges s [MatchedEdgeSummary s]-  deriving stock (Eq, Show)---- | Like 'step', but returns a precise 'StepFailure' explanation on the--- 'Left' instead of collapsing every failure into 'Nothing'. On the--- 'Right' it returns EXACTLY the triple 'step' returns. 'step' is left--- unchanged; this is purely additive.-stepEither ::-  (BoolAlg phi (RegFile rs, ci)) =>-  SymTransducer phi rs s ci co ->-  (s, RegFile rs) ->-  ci ->-  Either (StepFailure s) (s, RegFile rs, [co])-stepEither t (s, regs) ci =-  case zip [0 ..] (edgesOut t s) of-    [] -> Left (NoOutgoingEdges s)-    indexed ->-      let matched =-            [ (i, e)-            | (i, e) <- indexed,-              models (guard e) (regs, ci)-            ]-       in case matched of-            [] ->-              Left $-                NoMatchingEdge-                  s-                  [ RejectedEdgeSummary-                      { rejectedEdge = EdgeRef {edgeSource = s, edgeIndex = i},-                        rejectedTarget = target e,-                        rejectedGuard = False-                      }-                  | (i, e) <- indexed-                  ]-            [(_, e)] ->-              let !regs' = applyEdgeUpdate e regs ci-                  outs = [evalOut o regs ci | o <- output e]-               in Right (target e, regs', outs)-            _ ->-              Left $-                AmbiguousEdges-                  s-                  [ MatchedEdgeSummary-                      { matchedEdge = EdgeRef {edgeSource = s, edgeIndex = i},-                        matchedTarget = target e-                      }-                  | (i, e) <- matched-                  ]---- | Apply one observed output to the state by walking outgoing edges,--- inverting each edge's @output@ via 'solveOutput', verifying the--- guard on the recovered input, and applying the edge's @update@.--- Used by 'reconstitute' for full-log replay and exposed so that--- single-event façades (notably 'Keiki.Decider.toDecider') can--- implement an @evolve :: s -> e -> s@ step on top of it.------ == Letter-only semantics------ This function handles ε-edges (@output = []@; skipped because they--- emit nothing observable) and letter edges (@output = [o]@;--- inverted via 'solveOutput'). For multi-event edges (@output =--- [o1, ..., oN]@ with N >= 2), this letter-flavoured 'applyEvent'--- only inverts against the *head* of the output list, returning the--- target vertex on a successful match. It is suitable when the--- caller knows it is replaying letter-only events; for true--- streaming replay across multi-event edges (where intermediate--- events in the chain must be matched against the expected tail of--- a prior edge's output list) use 'applyEventStreaming'.-applyEvent ::-  (BoolAlg phi (RegFile rs, ci), Eq co) =>-  SymTransducer phi rs s ci co ->-  s ->-  RegFile rs ->-  co ->-  Maybe (s, RegFile rs)-applyEvent t s regs co =-  case [ (target e, applyEdgeUpdate e regs ci)-       | e <- edgesOut t s,-         o : _ <- [output e],-         Just ci <- [solveOutput o regs co],-         models (guard e) (regs, ci)-       ] of-    [single] -> Just single-    _ -> Nothing---- | Streaming-replay state wrapper. Used by 'applyEventStreaming'--- (the InFlight-aware replay) and exposed as the carrier of the--- 'Keiki.Decider.evolveStreaming' field added in EP-19 M5.------ @'Settled' s@ is the state at a stable vertex — the next event--- must be the first emission of /some/ outgoing edge of @s@.------ @'InFlight' s [e2, ..., eN]@ is the mid-chain state at vertex--- @s@ (the *target* of the in-flight chain's edge; register updates--- have already been applied at the transition into 'InFlight'). The--- queue holds the *evaluated* expected events in order; the next--- observed event must equal the head, popping it; when the queue--- empties, the wrapper transitions to @'Settled' s@.------ See @docs/research/gsm-widening-design.md@ §4 for the formal--- treatment and a worked example on the @StartRegistration@ chain.-data InFlight s co-  = Settled !s-  | InFlight !s ![co]-  deriving (Eq, Show)---- | Apply one observed output to a streaming-replay state. Two arms:------   1. @'Settled' s@ — walk outgoing edges of @s@; find the unique---      edge whose @output@'s *head* inverts to a valid @ci@ via---      'solveOutput' satisfying the guard. Commit to that edge, run---      its update, evaluate the *tail* of the output list against---      the recovered @(regs, ci)@ snapshot. If the tail is empty---      (letter edge), return @('Settled' (target e), regs')@. If the---      tail is non-empty (multi-event edge), return @('InFlight'---      (target e) tail, regs')@.------   2. @'InFlight' s (q1 : rest) regs@ — equality-check @q1@---      against the observed event. On match, advance the queue---      (returning @'Settled' s@ when @rest == []@, otherwise---      @'InFlight' s rest@). No register update — registers were---      updated at the @Settled → InFlight@ transition. On mismatch---      (out-of-order replay) return 'Nothing'.------ The 'Eq' constraint on @co@ supports the queue equality check.--- Most aggregate event types derive 'Eq' (a documented expectation--- of the foundations).-applyEventStreaming ::-  (BoolAlg phi (RegFile rs, ci), Eq co) =>-  SymTransducer phi rs s ci co ->-  InFlight s co ->-  RegFile rs ->-  co ->-  Maybe (InFlight s co, RegFile rs)-applyEventStreaming t (Settled s) regs co =-  case [ (e, ci)-       | e <- edgesOut t s,-         o : _ <- [output e],-         Just ci <- [solveOutput o regs co],-         models (guard e) (regs, ci)-       ] of-    [(e, ci)] ->-      let regs' = applyEdgeUpdate e regs ci-          evaluatedTail = [evalOut o regs ci | o <- drop 1 (output e)]-          wrapped = case evaluatedTail of-            [] -> Settled (target e)-            xs -> InFlight (target e) xs-       in Just (wrapped, regs')-    _ -> Nothing-applyEventStreaming _ (InFlight s queue) regs co = case queue of-  [] -> Nothing-  [q1]-    | q1 == co -> Just (Settled s, regs)-    | otherwise -> Nothing-  (q1 : rest)-    | q1 == co -> Just (InFlight s rest, regs)-    | otherwise -> Nothing---- | Reconstitute @(state, registers)@ from a log of outputs by--- replaying each event through the InFlight-aware--- 'applyEventStreaming', which threads mid-chain state through--- multi-event edges invisibly and unwraps to 'Settled' at the log's--- end.------ For letter-only transducers (every edge has @output@ of length 0--- or 1) the streaming wrapper is always 'Settled' and the result is--- identical to the pre-EP-19 letter-fold. A log that ends mid-chain--- through a multi-event edge returns 'Nothing' — there is no valid--- @(s, regs)@ to surface from an 'InFlight' final state.-reconstitute ::-  (BoolAlg phi (RegFile rs, ci), Eq co) =>-  SymTransducer phi rs s ci co ->-  [co] ->-  Maybe (s, RegFile rs)-reconstitute t = applyEvents t (initial t, initialRegs t)---- | Replay a chunk of events from a caller-supplied--- @(state, registers)@ start. Structurally similar to 'reconstitute'--- except that the start state is an argument rather than the--- transducer's initial state, so a runtime adapter can chunk-replay--- the events corresponding to one logical command from any current--- state.------ Useful when the runtime preserves command boundaries (event store--- with command-id tags, transactional batches, deterministic test--- fixtures): replay one command's events as one atomic step and--- consume the unwrapped final state.------ == Multi-event edges (EP-19 M3)------ Internally, the implementation lifts the start state to 'Settled'--- and folds 'applyEventStreaming' over the chunk; the wrapper--- transitions through 'InFlight' for multi-event edges and unwraps--- back to 'Settled' when the chunk completes. A chunk that ends--- mid-flight (the queue is non-empty at the end of the input list)--- returns 'Nothing'; this signals a truncated chunk relative to the--- edge's static output length.------ For length-0/1 edges the behaviour is identical to the legacy--- letter-fold; for length-2+ edges the chunk must contain the full--- expected sequence of evaluated events in order.------ Returns 'Nothing' if any event in the chunk fails to replay (e.g.--- a malformed log, an event that does not match any active edge's--- output at the current vertex, or a chunk that ends mid-flight).-applyEvents ::-  (BoolAlg phi (RegFile rs, ci), Eq co) =>-  SymTransducer phi rs s ci co ->-  (s, RegFile rs) ->-  [co] ->-  Maybe (s, RegFile rs)-applyEvents t (s0, regs0) cos_ = go (Settled s0) regs0 cos_-  where-    go (Settled s) regs [] = Just (s, regs)-    go (InFlight _ _) _ [] = Nothing -- chunk ended mid-flight-    go inFlight regs (co : rest) = do-      (inFlight', regs') <- applyEventStreaming t inFlight regs co-      go inFlight' regs' rest---- * Build-time analyses -------------------------------------------------------- | Recover the input that produced a given output by walking--- 'OutFields' structurally against the input constructor named by the--- 'OPack'. Gather '(Index, value)' pairs from every top-level--- 'TInpCtorField' read whose 'InCtor' matches by 'icName'; assemble a--- 'RegFile' covering every slot of the 'InCtor'; call 'icBuild'.------ == Recompute-and-verify (EP-47)------ The command is recovered from the /invertible/ fields alone--- (@TLit@\/@TReg@\/@TInpCtorField@); /derived/ fields (@TArith@\/@TApp1@\/--- @TApp2@) are skipped during recovery by 'gatherInpEntries'. After the--- command is rebuilt, the observed field tuple is rebuilt with each--- /derived/ field recomputed forward (via 'recomputeDerivedFields') and--- the resulting event is required to equal the observed event, so each--- derived field is /verified/ rather than trusted — a tampered derived--- value is rejected. Invertible fields are kept at their observed values--- and are /not/ re-verified (so a @TReg@ audit field still round-trips--- even when replay starts from a state whose registers are not yet--- populated). This generalizes, at field granularity, the--- forward-recompute-and-@Eq@-match that 'applyEventStreaming' already does--- for multi-event tails (see @docs/research/recompute-and-verify-derived-outputs.md@).------ For an all-invertible edge no field is recomputed, so the rebuilt event--- equals the observed event by construction (the check is a no-op) and the--- result is identical to the pre-EP-47 behavior. The build-time net--- 'checkHiddenInputs' still rejects a schema whose command slot is read--- only inside a derived field (a hidden input), so the command remains--- recoverable from invertible fields alone — "the event determines the--- command" is preserved.-solveOutput :: (Eq co) => OutTerm rs ci co -> RegFile rs -> co -> Maybe ci-solveOutput (OPack ic@InCtor {} ctor fields) regs co = do-  fs_obs <- wcMatch ctor co-  entries <- gatherInpEntries fields fs_obs ic-  rf <- assemble entries-  let ci = icBuild ic rf-      -- Rebuild the observed field tuple, recomputing ONLY the derived-      -- fields (TApp/TArith) forward; invertible fields keep their observed-      -- value. Comparing the rebuilt event to the observed one then verifies-      -- exactly the derived fields — never the invertible ones, so a-      -- register-read audit field is not re-checked against state and the-      -- command thunk is not forced for an all-invertible edge.-      rebuilt = wcBuild ctor (recomputeDerivedFields fields fs_obs regs ci)-  if rebuilt == co-    then Just ci-    else Nothing---- | Rebuild an observed output-field tuple, recomputing each /derived/--- field ('TApp1'\/'TApp2'\/'TArith') forward via 'evalTerm' against the--- recovered command and the pre-update registers, while leaving every--- /invertible/ field ('TLit'\/'TReg'\/'TInpCtorField') at its observed--- value. Used by 'solveOutput' (EP-47 recompute-and-verify): comparing the--- rebuilt event to the observed one (via 'Eq' on @co@) then verifies--- exactly the derived fields. Invertible fields are deliberately /not/--- recomputed, so (a) a register-read audit field is not re-verified against--- the current register file — preserving the "@TReg@ round-trips" contract--- even when replay starts from a state whose registers are not yet--- populated — and (b) the recovered-command thunk is not forced for an--- all-invertible edge.-recomputeDerivedFields ::-  forall rs ci ifs fs. OutFields rs ci ifs fs -> fs -> RegFile rs -> ci -> fs-recomputeDerivedFields OFNil () _ _ = ()-recomputeDerivedFields (OFCons t rest) (v, vs) regs ci =-  (recomputeOne t v, recomputeDerivedFields rest vs regs ci)-  where-    recomputeOne :: forall f. Term rs ci ifs f -> f -> f-    recomputeOne term@(TApp1 _ _) _observed = evalTerm term regs ci-    recomputeOne term@(TApp2 _ _ _) _observed = evalTerm term regs ci-    recomputeOne term@(TArith _ _ _) _observed = evalTerm term regs ci-    recomputeOne _ observed = observed---- | Walk an 'OutFields' HList in lockstep with an observed-fields--- tuple, gathering '(Index, value)' pairs for the named 'InCtor' from--- the /invertible/ fields. 'TLit'\/'TReg' contribute nothing; a--- 'TInpCtorField' for the matching 'InCtor' contributes its--- '(Index, value)' pair. Since EP-47 the /derived/ fields--- ('TArith'\/'TApp1'\/'TApp2') are /skipped/ (they contribute no--- entries) rather than aborting the walk — 'solveOutput' verifies them--- forward afterwards. Returns 'Nothing' only on a genuinely malformed--- edge: a 'TInpCtorField' naming a /different/ 'InCtor' (a runtime--- diagnostic; soundness no longer depends on it — see below). 'assemble--- []' for an empty 'ifs' is 'Just RNil', so empty-payload input--- constructors recover trivially; and if a derived field is the /only/--- place a command slot is read, the skipped slot leaves 'assemble'--- short and 'solveOutput' fails — exactly the hidden-input case that--- 'checkHiddenInputs' flags at build time.------ == Type-safe index recovery (EP-53)------ Because 'OutFields' is indexed by the same input field schema @ifs@ as--- the 'OPack''s 'InCtor', a top-level 'TInpCtorField' inside this--- 'OutFields' carries an @'Index' ifs r@ /into the @OPack@'s schema by--- construction/. So @'ByIndex' ix val@ type-checks directly — no--- @unsafeCoerce@ — and a constructor whose field schema differs from the--- 'OPack''s 'InCtor' is rejected at compile time rather than coerced at--- run time. The @'icName' ic1 == 'icName' ic2@ guard is retained only as--- a defensive runtime diagnostic for an 'OutFields' that names a--- different (but same-schema) constructor.-gatherInpEntries ::-  forall rs ci ifs fs.-  OutFields rs ci ifs fs -> fs -> InCtor ci ifs -> Maybe [ByIndex ifs]-gatherInpEntries OFNil () _ic = Just []-gatherInpEntries (OFCons t rest) (v, fs) ic = do-  here <- stepOne t v ic-  more <- gatherInpEntries rest fs ic-  pure (here ++ more)-  where-    stepOne :: forall f. Term rs ci ifs f -> f -> InCtor ci ifs -> Maybe [ByIndex ifs]-    stepOne (TLit _) _val _ = Just []-    stepOne (TReg _) _val _ = Just []-    stepOne (TInpCtorField ic2 ix) val ic1-      | icName ic1 == icName ic2 = Just [ByIndex ix val]-      | otherwise = Nothing-    -- Derived fields are skipped here and verified forward by-    -- 'solveOutput' (EP-47 recompute-and-verify); they contribute no-    -- command information of their own.-    stepOne (TApp1 _ _) _val _ = Just []-    stepOne (TApp2 _ _ _) _val _ = Just []-    stepOne (TArith _ _ _) _val _ = Just []---- | A diagnostic produced by 'checkHiddenInputs'.-data HiddenInputWarning = HiddenInputWarning-  { -- | Description of the edge's source (typically @show s@).-    hiwEdgeSource :: String,-    -- | Human-readable description of what's hidden.-    hiwReason :: String-  }-  deriving (Eq, Show)---- | For every edge in the transducer, check whether the @output@ can--- mechanically recover the input on replay. Specifically:------   * If @output@ is @[]@ (an ε-edge), and @update@ reads the input---     symbol, that contribution is silent on the wire and---     unrecoverable.---   * If @output@ is non-empty, the per-edge check groups the---     'OPack's by 'InCtor' name (via 'icName') and computes the---     *union* of slots visited across every 'OPack' naming the same---     'InCtor'. If the union still leaves any of the 'InCtor''s---     slots unvisited, the warning names the 'InCtor' and the---     missing slot(s).------ For length-1 edges this matches the legacy per-'OPack' check--- (there is only one 'OPack' so the union is trivial). For length-2+--- edges the union strengthening means an 'InCtor' jointly recovered--- by multiple 'OPack's in the same edge — none of which covers all--- slots alone, but together they do — does *not* fire the warning.------ The check is intentionally conservative: it flags candidates for--- the author to inspect, not theorems.-checkHiddenInputs ::-  forall phi rs s ci co.-  (Bounded s, Enum s, Show s) =>-  SymTransducer phi rs s ci co ->-  [HiddenInputWarning]-checkHiddenInputs t =-  [ HiddenInputWarning-      { hiwEdgeSource = show s,-        hiwReason = formatHiddenInputReason n r-      }-  | s <- [minBound .. maxBound],-    (n, e) <- zip [(0 :: Int) ..] (edgesOut t s),-    r <- hiddenInputReasons e-  ]---- | A structured reason an edge's output cannot mechanically recover its--- input on replay. This is the single source of truth behind both--- 'checkHiddenInputs' (which formats these into the legacy 'HiddenInputWarning'--- strings via 'formatHiddenInputReason') and 'hiddenInputWarnings' (which lifts--- each into a structured 'TransducerValidationWarning' carrying the typed source--- vertex, the input-constructor name, and the missing slot names).-data HiddenInputReason-  = -- | An ε-edge (empty @output@) whose @update@ reads the input symbol,-    --       so the read information is silent on the wire.-    HirEpsilonReadsInput-  | -- | The named input constructor has declared slots the edge's output-    --       never recovers (after unioning every same-constructor 'OPack').-    --       Carries the constructor name and the missing slot names.-    HirUnionMiss String [String]-  deriving (Eq, Show)---- | The per-edge hidden-input analysis, factored out of 'checkHiddenInputs'--- so the legacy string warnings and the structured 'hiddenInputWarnings' share--- one implementation. For an ε-edge it reports 'HirEpsilonReadsInput' iff the--- update reads the input; for a non-empty output it groups 'OPack's by input--- constructor name, unions the recovered slots, and reports a 'HirUnionMiss' for--- any constructor with uncovered slots (first-seen order, deterministic).-hiddenInputReasons ::-  forall phi rs ci co s. Edge phi rs ci co s -> [HiddenInputReason]-hiddenInputReasons e = case output e of-  []-    | edgeReadsInput e -> [HirEpsilonReadsInput]-    | otherwise -> []-  outs ->-    [ HirUnionMiss icN missing-    | (icN, allSlots, visitedUnion) <- groupByInCtorName outs,-      let missing = allSlots \\ nub visitedUnion,-      not (null missing)-    ]-  where-    -- Walk the output list, accumulating per-InCtor (slot list, visited-    -- slots). First seen wins on the slot list; subsequent OPacks with the-    -- same InCtor name extend the visited list.-    groupByInCtorName ::-      [OutTerm rs ci co] -> [(String, [String], [String])]-    groupByInCtorName = foldl add []-      where-        add acc (OPack ic _ fields) =-          let icN = icName ic-              allSl = slotNamesOf ic-              visited = visitedSlotsOf ic fields-           in extend acc icN allSl visited--        extend [] icN allSl visited = [(icN, allSl, visited)]-        extend ((n, sl, v) : rest) icN allSl visited-          | n == icN = (n, sl, v ++ visited) : rest-          | otherwise = (n, sl, v) : extend rest icN allSl visited--    -- Slots of an OPack's named 'InCtor' that the supplied 'OutFields' walk-    -- recovers via a /top-level/ 'TInpCtorField'. Since EP-47 this does NOT-    -- descend into derived ('TApp1'\/'TApp2'\/'TArith') terms: a slot read-    -- only inside a derived field is a /hidden input/, so it is reported-    -- missing rather than counted as covered.-    visitedSlotsOf ::-      forall ifs fs.-      InCtor ci ifs -> OutFields rs ci ifs fs -> [String]-    visitedSlotsOf ic@InCtor {} fields = goFields fields-      where-        allSlots = slotNamesOf ic--        goFields :: forall fs'. OutFields rs ci ifs fs' -> [String]-        goFields OFNil = []-        goFields (OFCons tt rest) = goTerm tt ++ goFields rest--        goTerm :: forall r. Term rs ci ifs r -> [String]-        goTerm (TInpCtorField ic2 ix)-          | icName ic2 == icName ic =-              [allSlots !! indexPos ix]-          | otherwise = []-        goTerm _ = [] -- do not descend into derived terms-        indexPos :: forall rs' r. Index rs' r -> Int-        indexPos ZIdx = 0-        indexPos (SIdx i) = 1 + indexPos i---- | Format a 'HiddenInputReason' into the legacy 'HiddenInputWarning' reason--- string. The output is byte-identical to the pre-refactor 'checkHiddenInputs'--- text so existing consumers and tests are unaffected.-formatHiddenInputReason :: Int -> HiddenInputReason -> String-formatHiddenInputReason n HirEpsilonReadsInput =-  "edge #" <> show n <> ": ε-edge with input read in update"-formatHiddenInputReason n (HirUnionMiss icN missing) =-  "edge #"-    <> show n-    <> ": OPack walk for InCtor \""-    <> icN-    <> "\" leaves field"-    <> (if length missing == 1 then " " else "s ")-    <> "{"-    <> showMissing missing-    <> "} unrecovered"-  where-    showMissing :: [String] -> String-    showMissing [] = ""-    showMissing [x] = "\"" <> x <> "\""-    showMissing (x : xs) = "\"" <> x <> "\", " <> showMissing xs---- | Does the 'Update' read the input symbol via 'TInpCtorField'?-updateReadsInput :: Update rs w ci -> Bool-updateReadsInput UKeep = False-updateReadsInput (USet _ t) = termReadsInput t-updateReadsInput (UCombine a b) = updateReadsInput a || updateReadsInput b---- | Does the 'Term' read the input symbol via 'TInpCtorField'?-termReadsInput :: Term rs ci ifs r -> Bool-termReadsInput (TLit _) = False-termReadsInput (TReg _) = False-termReadsInput (TInpCtorField _ _) = True-termReadsInput (TApp1 _ t) = termReadsInput t-termReadsInput (TApp2 _ a b) = termReadsInput a || termReadsInput b-termReadsInput (TArith _ a b) = termReadsInput a || termReadsInput b---- | Do the 'OutFields' contain a 'TInpCtorField' read anywhere?-outFieldsHaveInpCtorField :: OutFields rs ci ifs fs -> Bool-outFieldsHaveInpCtorField OFNil = False-outFieldsHaveInpCtorField (OFCons t rest) =-  termHasInpCtorField t || outFieldsHaveInpCtorField rest-  where-    termHasInpCtorField :: Term rs ci ifs r -> Bool-    termHasInpCtorField (TLit _) = False-    termHasInpCtorField (TReg _) = False-    termHasInpCtorField (TInpCtorField _ _) = True-    termHasInpCtorField (TApp1 _ t') = termHasInpCtorField t'-    termHasInpCtorField (TApp2 _ a b) = termHasInpCtorField a || termHasInpCtorField b-    termHasInpCtorField (TArith _ a b) = termHasInpCtorField a || termHasInpCtorField b---- | The result of 'detectMissingInCtorFields': the offending 'InCtor'--- name plus the names of slots its 'OutFields' walk does not visit.-data MissingInCtorFields = MissingInCtorFields-  { mifIcName :: String,-    mifMissing :: [String]-  }-  deriving (Eq, Show)---- | Given the 'InCtor' an 'OPack' is tagged with and that 'OPack'\'s--- 'OutFields', return the field names of the 'InCtor' that the--- 'OutFields' walk does not visit. 'Nothing' means every slot of the--- 'InCtor' is visited. The slot list comes from the 'InCtor' itself--- (via 'KnownSlotNames'), not from any 'TInpCtorField' inside the--- 'OutFields' — this lets us flag empty 'OutFields' against a non---- empty 'InCtor' as well.-detectMissingInCtorFields ::-  forall rs ci ifs fs.-  InCtor ci ifs ->-  OutFields rs ci ifs fs ->-  Maybe MissingInCtorFields-detectMissingInCtorFields ic@InCtor {} fields =-  case allSlots \\ nub visited of-    [] -> Nothing-    missing -> Just (MissingInCtorFields (icName ic) missing)-  where-    allSlots = slotNamesOf ic-    visited = goFields fields--    goFields :: forall fs'. OutFields rs ci ifs fs' -> [String]-    goFields OFNil = []-    goFields (OFCons t rest) = goTerm t ++ goFields rest--    goTerm :: forall r. Term rs ci ifs r -> [String]-    goTerm (TInpCtorField ic2 ix)-      | icName ic2 == icName ic =-          [allSlots !! indexPos ix]-      | otherwise = []-    goTerm _ = [] -- EP-47: top-level reads only; derived-    -- (TApp/TArith) terms are not descended-    -- into, so a slot read only inside one is-    -- reported missing (a hidden input).-    indexPos :: forall rs' r. Index rs' r -> Int-    indexPos ZIdx = 0-    indexPos (SIdx i) = 1 + indexPos i---- | Read the slot-name list out of an 'InCtor' (uses the--- 'KnownSlotNames' instance carried by the data constructor).-slotNamesOf :: forall ci ifs. InCtor ci ifs -> [String]-slotNamesOf InCtor {} = slotNames @ifs---- * Build-time validation umbrella (EP-56) ------------------------------------ | A structured build-time validation warning, parameterized over the--- vertex type @s@ so it carries the real source vertex rather than a--- pre-stringified one. It reuses the canonical 'EdgeRef' locator owned by EP-55--- (the runtime explainer 'stepEither'), so the runtime and build-time--- diagnostics speak one vocabulary.------ Produced by 'validateTransducer'. The three kinds correspond to the three--- authoring mistakes the consumer audit flagged: hidden replay inputs,--- nondeterministic (overlapping) guards, and edges that can never fire.-data TransducerValidationWarning s-  = -- | An edge consumes command information that its output does not-    --       emit, so the command cannot be reconstructed on replay.-    HiddenInput-      { tvwEdge :: EdgeRef s,-        -- | input constructor name, when known-        tvwInCtor :: Maybe String,-        -- | slot/field names left off the wire-        tvwMissingSlots :: [String],-        -- | human-readable summary-        tvwDetail :: String-      }-  | -- | Two outgoing edges of the same vertex whose guards can both hold-    --       for one command — a runtime nondeterminism / single-valuedness-    --       violation (its dynamic witness is EP-55's @AmbiguousEdges@).-    NondeterministicPair-      { tvwSource :: s,-        tvwEdgeA :: Int,-        tvwEdgeB :: Int,-        tvwInCtor :: Maybe String,-        tvwDetail :: String-      }-  | -- | An edge that can never fire: its source vertex is unreachable-    --       from 'initial', or its guard is statically unsatisfiable. Labelled-    --       "possibly" because the structural pass is conservative.-    PossiblyDeadEdge-      { tvwEdge :: EdgeRef s,-        tvwDetail :: String-      }-  | -- | An edge whose guard contains an opaque 'TApp' term. The symbolic-    --       single-valuedness and dead-edge analyses translate such a term to an-    --       unconstrained free variable ('Keiki.Symbolic.translateTermSym' emits-    --       @SBV.free "app1"@), so they cannot see through the guard and silently-    --       under-verify it. Most often this is a collection-content condition-    --       (membership, "all resolved", size) lifted through a closure because the-    --       structural predicate language has no node for it; see the user guide and-    --       @docs\/plans\/60-first-class-collection-registers-design-gated.md@ for the-    --       options. Advisory, not a soundness error: opt in via 'warnOpaqueGuards'.-    OpaqueGuard-      { tvwEdge :: EdgeRef s,-        tvwDetail :: String-      }-  deriving stock (Eq, Show)---- | Which checks 'validateTransducer' runs. All default to 'True' (see--- 'defaultValidationOptions').-data ValidationOptions = ValidationOptions-  { -- | run the hidden-input check-    failOnEpsilonReadsInput :: Bool,-    -- | run the (pure, structural) determinism check-    checkDeterminism :: Bool,-    -- | run the (structural) dead-edge check-    checkReachability :: Bool,-    -- | run the opaque-guard audit (opt-in; default off). Flags edges whose-    --     guard branches on an opaque 'TApp' term the symbolic analyses cannot-    --     see through. Off by default so 'defaultValidationOptions' keeps its-    --     meaning for existing consumers.-    warnOpaqueGuards :: Bool-  }-  deriving stock (Eq, Show)---- | The three soundness checks enabled; the opt-in opaque-guard audit off.-defaultValidationOptions :: ValidationOptions-defaultValidationOptions =-  ValidationOptions-    { failOnEpsilonReadsInput = True,-      checkDeterminism = True,-      checkReachability = True,-      warnOpaqueGuards = False-    }---- | The build-time validation umbrella. Runs the enabled checks over the--- 'HsPred' (syntactic, /no solver/) carrier and concatenates their structured--- warnings, so a project can put @validateTransducer defaultValidationOptions t--- == []@ directly in a unit test and have it pass or fail in microseconds with--- no external z3 process.------ The default path is deliberately specialised to the 'HsPred' carrier and is--- /cheap and pure/: the determinism component flags only structurally-provable--- overlaps (never a false positive, but it can miss overlaps it cannot prove--- syntactically), and the dead-edge component is structural reachability plus a--- literal-'PBot' check. For the exact, solver-backed answers use--- 'Keiki.Symbolic.checkTransitionDeterminismSym' and--- 'Keiki.Symbolic.checkDeadEdgesSym' directly.-validateTransducer ::-  (Bounded s, Enum s, Ord s, Show s) =>-  ValidationOptions ->-  SymTransducer (HsPred rs ci) rs s ci co ->-  [TransducerValidationWarning s]-validateTransducer opts t =-  concat-    [ if failOnEpsilonReadsInput opts then hiddenInputWarnings t else [],-      if checkDeterminism opts then determinismWarnings t else [],-      if checkReachability opts-        then-          [ PossiblyDeadEdge (dewEdge w) (dewReason w)-          | w <- checkDeadEdges defaultDeadEdgeOptions t-          ]-        else [],-      if warnOpaqueGuards opts then opaqueGuardWarnings t else []-    ]---- | Structured form of the hidden-input check, additive over--- 'checkHiddenInputs'. Reuses the same per-edge analysis ('hiddenInputReasons')--- and lifts each result into a 'TransducerValidationWarning' carrying the typed--- source vertex (via 'EdgeRef'), the input-constructor name, and the missing--- slot names — data a downstream project can pattern-match on rather than parse--- out of a string.-hiddenInputWarnings ::-  (Bounded s, Enum s) =>-  SymTransducer phi rs s ci co ->-  [TransducerValidationWarning s]-hiddenInputWarnings t =-  [ HiddenInput-      { tvwEdge = EdgeRef {edgeSource = s, edgeIndex = n},-        tvwInCtor = inCtorOf r,-        tvwMissingSlots = missingSlotsOf r,-        tvwDetail = formatHiddenInputReason n r-      }-  | s <- [minBound .. maxBound],-    (n, e) <- zip [(0 :: Int) ..] (edgesOut t s),-    r <- hiddenInputReasons e-  ]-  where-    inCtorOf (HirUnionMiss icN _) = Just icN-    inCtorOf HirEpsilonReadsInput = Nothing-    missingSlotsOf (HirUnionMiss _ ms) = ms-    missingSlotsOf HirEpsilonReadsInput = []---- ** Opaque-guard diagnostics---- | Does the term contain an opaque 'TApp1'\/'TApp2' anywhere? Mirrors the--- structural recursion of 'termReadsInput'; 'TArith' is transparent, so it--- recurses into its operands rather than counting as opaque.-termHasOpaqueApp :: Term rs ci ifs r -> Bool-termHasOpaqueApp (TLit _) = False-termHasOpaqueApp (TReg _) = False-termHasOpaqueApp (TInpCtorField _ _) = False-termHasOpaqueApp (TApp1 _ _) = True-termHasOpaqueApp (TApp2 _ _ _) = True-termHasOpaqueApp (TArith _ a b) = termHasOpaqueApp a || termHasOpaqueApp b---- | Does the guard predicate branch on an opaque term anywhere? The symbolic--- analyses cannot see through such a guard (it becomes a free SBV variable),--- so they silently under-verify the edge.-predHasOpaqueTerm :: HsPred rs ci -> Bool-predHasOpaqueTerm PTop = False-predHasOpaqueTerm PBot = False-predHasOpaqueTerm (PAnd p q) = predHasOpaqueTerm p || predHasOpaqueTerm q-predHasOpaqueTerm (POr p q) = predHasOpaqueTerm p || predHasOpaqueTerm q-predHasOpaqueTerm (PNot p) = predHasOpaqueTerm p-predHasOpaqueTerm (PEq a b) = termHasOpaqueApp a || termHasOpaqueApp b-predHasOpaqueTerm (PInCtor _) = False-predHasOpaqueTerm (PCmp _ a b) = termHasOpaqueApp a || termHasOpaqueApp b---- | The opt-in opaque-guard audit (run by 'validateTransducer' only when--- 'warnOpaqueGuards' is set). For every edge whose guard contains an opaque--- 'TApp' term, emit an 'OpaqueGuard' warning locating the edge by its typed--- 'EdgeRef'. Specialised to the 'HsPred' carrier because it walks the predicate--- AST, exactly as 'validateTransducer' is.-opaqueGuardWarnings ::-  (Bounded s, Enum s) =>-  SymTransducer (HsPred rs ci) rs s ci co ->-  [TransducerValidationWarning s]-opaqueGuardWarnings t =-  [ OpaqueGuard-      { tvwEdge = EdgeRef {edgeSource = s, edgeIndex = n},-        tvwDetail =-          "guard contains an opaque TApp term the symbolic analyses cannot "-            ++ "see through; its single-valuedness was not verified"-      }-  | s <- [minBound .. maxBound],-    (n, e) <- zip [(0 :: Int) ..] (edgesOut t s),-    predHasOpaqueTerm (guard e)-  ]---- ** Determinism diagnostics---- | A determinism warning: two outgoing edges of the same vertex whose guards--- can both hold. Carries both edge indices and the (typed) source vertex.-data DeterminismWarning s = DeterminismWarning-  { dwSource :: s,-    -- | first overlapping edge index-    dwEdgeA :: Int,-    -- | second overlapping edge index-    dwEdgeB :: Int,-    dwDetail :: String-  }-  deriving stock (Eq, Show)---- | Per-vertex, per-pair determinism diagnostic. Reuses the exact pairing--- structure of 'Keiki.Symbolic.isSingleValuedSym': for every vertex, for every--- pair @(i,e1),(j,e2)@ with @i<j@, the pair is ambiguous when--- @guard e1 \`conj\` guard e2@ is /not/ 'isBot'. So--- @checkTransitionDeterminism t == []@ iff @isSingleValuedSym t@ under the same--- carrier.------ Soundness direction: with the pure 'HsPred' carrier, 'isBot' only recognises--- the literal 'PBot', so @not (isBot (a \`conj\` b))@ holds for /every/ non-'PBot'--- pair — i.e. this polymorphic check over-approximates overlap on the 'HsPred'--- carrier (it would flag almost every multi-edge vertex). It is intended to be--- run over the /symbolic/ 'SymPred' carrier (via--- 'Keiki.Symbolic.checkTransitionDeterminismSym'), whose 'isBot' is exact. For--- the pure path 'validateTransducer' uses the under-approximating--- 'checkTransitionDeterminismPure' instead, which flags only true positives.-checkTransitionDeterminism ::-  forall phi rs s ci co.-  (BoolAlg phi (RegFile rs, ci), Bounded s, Enum s, Show s) =>-  SymTransducer phi rs s ci co ->-  [DeterminismWarning s]-checkTransitionDeterminism t =-  [ DeterminismWarning-      { dwSource = s,-        dwEdgeA = i,-        dwEdgeB = j,-        dwDetail = overlapDetail i j s-      }-  | s <- [minBound .. maxBound],-    let ies = zip [(0 :: Int) ..] (edgesOut t s),-    (i, e1) <- ies,-    (j, e2) <- ies,-    i < j,-    not (isBot (guard e1 `conj` guard e2))-  ]---- | Over-approximation-free determinism check for the pure 'HsPred' carrier:--- emits a warning only when overlap is structurally provable (both guards are--- 'PTop', or both name the same input constructor). Used by 'validateTransducer'--- so the pure path yields no false positives. Every warning it emits is a true--- positive; the absence of a warning does NOT prove determinism — run--- 'Keiki.Symbolic.checkTransitionDeterminismSym' for the exact answer.-checkTransitionDeterminismPure ::-  forall rs s ci co.-  (Bounded s, Enum s, Show s) =>-  SymTransducer (HsPred rs ci) rs s ci co ->-  [DeterminismWarning s]-checkTransitionDeterminismPure t =-  [ DeterminismWarning-      { dwSource = s,-        dwEdgeA = i,-        dwEdgeB = j,-        dwDetail = overlapDetail i j s-      }-  | s <- [minBound .. maxBound],-    let ies = zip [(0 :: Int) ..] (edgesOut t s),-    (i, e1) <- ies,-    (j, e2) <- ies,-    i < j,-    provablyOverlap (guard e1) (guard e2)-  ]--overlapDetail :: (Show s) => Int -> Int -> s -> String-overlapDetail i j s =-  "edges #"-    <> show i-    <> " and #"-    <> show j-    <> " out of "-    <> show s-    <> " have overlapping guards"---- | Structurally-provable guard overlap for the pure 'HsPred' carrier: 'True'--- only when overlap is certain (both 'PTop', or the same input constructor).--- Conservative — never a false positive; misses overlaps it cannot prove--- syntactically (those are left to the symbolic variant).-provablyOverlap :: HsPred rs ci -> HsPred rs ci -> Bool-provablyOverlap PTop PTop = True-provablyOverlap (PInCtor a) (PInCtor b) = icName a == icName b-provablyOverlap _ _ = False---- | Internal: the determinism component of 'validateTransducer'. Like--- 'checkTransitionDeterminismPure' but emits the richer 'NondeterministicPair'--- directly, populating 'tvwInCtor' with the overlapping command constructor when--- both guards name the same one (and 'Nothing' for the 'PTop' case).-determinismWarnings ::-  (Bounded s, Enum s, Show s) =>-  SymTransducer (HsPred rs ci) rs s ci co ->-  [TransducerValidationWarning s]-determinismWarnings t =-  [ NondeterministicPair-      { tvwSource = s,-        tvwEdgeA = i,-        tvwEdgeB = j,-        tvwInCtor = overlapCtor (guard e1) (guard e2),-        tvwDetail = overlapDetail i j s-      }-  | s <- [minBound .. maxBound],-    let ies = zip [(0 :: Int) ..] (edgesOut t s),-    (i, e1) <- ies,-    (j, e2) <- ies,-    i < j,-    provablyOverlap (guard e1) (guard e2)-  ]-  where-    overlapCtor (PInCtor a) (PInCtor b)-      | icName a == icName b = Just (icName a)-    overlapCtor _ _ = Nothing+{-# LANGUAGE TypeAbstractions #-}+-- Validation diagnostics intentionally expose constructor-specific record+-- selectors. Changing them to total fields would break the public diagnostic+-- API, so keep the partiality explicit and silence the definition-site warning.+{-# OPTIONS_GHC -Wno-partial-fields -Wno-redundant-constraints #-}++-- | The pure core of keiki: the symbolic-register transducer.+--+-- This module is the v1 prototype of the design pinned by+-- @docs/research/dsl-shape-for-symbolic-register.md@ (the DSL note),+-- @docs/research/effects-boundary.md@ (the boundary note), and+-- @docs/research/synthesis-c-foundation-b-presentation-with-worked-examples.md@+-- (the working baseline). See those notes for the rationale behind every+-- shape declared here.+--+-- All v1 escape hatches were retired by MasterPlan 6 (see the+-- Outcomes section of+-- @docs/masterplans/6-retire-remaining-v1-escape-hatches-in-pure-core-ofn-pmatchc-unsafecombine-static-check.md@):+-- @TInpField@ / @OPack@'s hand-written inverse (MP-2 EP-1), 'OFn' /+-- 'mkOut' (MP-6 EP-16), 'PMatchC' / 'matchCmd' (MP-6 EP-17), and+-- 'unsafeCombine' (MP-6 EP-18, replaced by the static 'Disjoint'+-- check on 'combine').+--+-- == Guard-authoring operators (EP-45)+--+-- Predicates and term arithmetic can be written with infix operators+-- that mirror their Prelude counterparts:+--+--   * Relational (build 'HsPred', @infix 4@): '.<' '.<=' '.>' '.>='+--     '.==' './=' — each an alias for 'PCmp'/'PEq' at a fixed relation.+--   * Logical (combine 'HsPred'): '.&&' (@infixr 3@, 'PAnd'),+--     '.||' (@infixr 2@, 'POr'), 'pnot' ('PNot').+--   * Arithmetic (build 'Term', mirror @+@\/@-@\/@*@): '.+' '.-' '.*' —+--     aliases for 'tadd'\/'tsub'\/'tmul'.+--+-- The verbose carrier signatures have synonyms: 'Pred' @rs ci@ for+-- @'HsPred' rs ci@, 'Guarded' @rs s ci co@ for+-- @'SymTransducer' ('HsPred' rs ci) rs s ci co@ (and+-- 'Keiki.Symbolic.SymGuarded' for the SBV-backed carrier).+--+-- Keep spaces around the operators (@lit a .* lit b@); a dot touching an+-- identifier (@x.y@) is OverloadedRecordDot field access. If you import+-- "Data.SBV" alongside this module, import it qualified — SBV exports+-- the same operator names.+module Keiki.Core+  ( -- * Slots and the register file+    Slot,+    RegFile (..),+    Index (..),+    (!),++    -- * Index resolution from labels+    HasIndex (..),++    -- * Term language+    Term (..),+    NumOp (..),++    -- * Input-side structural constructor (v2)+    InCtor (..),+    AssembleRegFile,+    KnownSlotNames (..),+    slotNamesOf,++    -- * Slot-name machinery (re-exported from "Keiki.Internal.Slots")+    IndexN (..),+    HasIndexN (..),+    Disjoint,+    DistinctNames,+    Concat,+    Names,++    -- * Update language+    Update (..),+    combine,++    -- * Output term language+    WireCtor (..),+    OutFields (..),+    (*:),+    oNil,+    OutTerm (..),++    -- * Predicate carrier (v1 first-class AST)+    HsPred (..),+    Pred,+    Cmp (..),++    -- * Effective Boolean algebra+    BoolAlg (..),+    Sat (..),++    -- * Edges and the transducer+    Edge (..),+    SymTransducer (..),+    Guarded,+    applyEdgeUpdate,+    edgeReadsInput,++    -- * Helpers (the user-facing DSL surface)+    matchInCtor,+    proj,+    inpCtor,+    lit,+    tadd,+    tsub,+    tmul,+    (.==),+    (.<),+    (.<=),+    (.>),+    (.>=),+    (./=),+    (.&&),+    (.||),+    pnot,+    (.+),+    (.-),+    (.*),+    pack,++    -- * Evaluators+    evalTerm,+    evalOut,+    evalPred,+    runUpdate,+    delta,+    omega,++    -- * Pure-layer entry points (effects-boundary note)+    step,+    stepEither,+    StepFailure (..),+    EdgeRef (..),+    RejectedEdgeSummary (..),+    MatchedEdgeSummary (..),+    ReplayStepFailure (..),+    ReplayFailureReason (..),+    ReplayFailure (..),+    reconstitute,+    reconstituteEither,+    applyEvent,+    applyEventStreaming,+    applyEventStreamingEither,+    applyEvents,+    applyEventsEither,+    replayEvents,++    -- * Streaming-replay state wrapper (EP-19 M3)+    InFlight (..),++    -- * Build-time analyses+    solveOutput,+    HiddenInputWarning (..),+    checkHiddenInputs,++    -- * Build-time validation umbrella (EP-56)+    TransducerValidationWarning (..),+    ValidationOptions (..),+    defaultValidationOptions,+    validateTransducer,+    hiddenInputWarnings,+    headRecoverabilityWarnings,+    inversionAmbiguityWarnings,+    guardImpliesInputReadWarnings,+    stateChangingEpsilonWarnings,+    opaqueGuardWarnings,+    DeterminismWarning (..),+    checkTransitionDeterminism,+    checkTransitionDeterminismPure,+    DeadEdgeOptions (..),+    defaultDeadEdgeOptions,+    DeadEdgeWarning (..),+    checkDeadEdges,++    -- * Internals exposed for testing+    termReadsInput,+    updateReadsInput,+    outFieldsHaveInpCtorField,+    HiddenInputReason (..),+    hiddenInputReasons,+    detectMissingInCtorFields,+    MissingInCtorFields (..),+  )+where++import Data.Int (Int32, Int64)+import Data.Kind (Type)+import Data.List (nub, partition, (\\))+import Data.Proxy (Proxy (..))+import Data.Set qualified as Set+import Data.Typeable (Typeable)+import Data.Word (Word16, Word32, Word64, Word8)+import GHC.OverloadedLabels (IsLabel (..))+import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)+import Keiki.Internal.Slots+  ( Concat,+    Disjoint,+    DistinctNames,+    HasIndexN (..),+    IndexN (..),+    Names,+  )+import Type.Reflection (eqTypeRep, typeRep, type (:~~:) (HRefl))++-- | A register slot is a label paired with the type of its value.+type Slot = (Symbol, Type)++-- * Register file -----------------------------------------------------------++-- | A typed heterogeneous register tuple indexed by a list of 'Slot's.+--+-- The slot-value field is intentionally lazy: 'Keiki.Generics.emptyRegFile'+-- seeds each slot with a deferred @error "uninit: \<slot\>"@ thunk so+-- that reading an unwritten slot fails loudly with a targeted message+-- instead of returning a silent bottom. Strictness for *written*+-- slots is enforced on the write path ('setSlotN') instead — see+-- EP-23's Surprises entry for the long-running-service rationale.+data RegFile (rs :: [Slot]) where+  RNil :: RegFile '[]+  RCons ::+    (KnownSymbol s) =>+    Proxy s -> r -> RegFile rs -> RegFile ('(s, r) ': rs)++-- | A type-safe pointer into a 'RegFile'. 'ZIdx' picks the head;+-- 'SIdx' skips one slot.+data Index (rs :: [Slot]) (r :: Type) where+  ZIdx :: (KnownSymbol s) => Index ('(s, r) ': rs) r+  SIdx :: Index rs r -> Index ('(s', r') ': rs) r++-- | Runtime register lookup. Matching on 'Index' first lets GHC's GADT+-- pattern checker see that 'RNil' is unreachable — 'ZIdx' and 'SIdx'+-- both refine @rs@ to @'(_,_) ': _@.+(!) :: RegFile rs -> Index rs r -> r+regs ! ZIdx = case regs of RCons _ x _ -> x+regs ! SIdx i = case regs of RCons _ _ rest -> rest ! i++infixl 9 !++-- * IsLabel / HasIndex -----------------------------------------------------++-- | Resolve a label @s@ against a slot list @rs@ to an 'Index' for the+-- value at that slot. The functional dependency @s rs -> r@ ensures that+-- a label uniquely determines the slot's type.+class+  HasIndex (s :: Symbol) (rs :: [Slot]) (r :: Type)+    | s rs -> r+  where+  indexOf :: Index rs r++instance+  {-# OVERLAPPING #-}+  (KnownSymbol s) =>+  HasIndex s ('(s, r) ': rs) r+  where+  indexOf = ZIdx++instance+  {-# OVERLAPPABLE #-}+  forall s s' r r' rs.+  (HasIndex s rs r) =>+  HasIndex s ('(s', r') ': rs) r+  where+  indexOf = SIdx (indexOf @s @rs @r)++instance+  forall s rs r.+  (HasIndex s rs r) =>+  IsLabel s (Index rs r)+  where+  fromLabel = indexOf @s @rs @r++-- | Resolve a label directly to a 'Term' that reads the named register.+-- This instance lets call sites write @#name@ in any 'Term'-typed+-- context (the arguments of 'requireEq', the elements of 'OutFields',+-- etc.) without the @proj (#name :: Index Regs T)@ annotation that+-- 'IsLabel s (Index rs r)' alone would require.+--+-- The two 'IsLabel' instances ('Index' and 'Term') coexist because GHC+-- dispatches by the expected result type: a context expecting an+-- 'Index' (e.g. 'inpFoo'\'s argument) selects the 'Index' instance; a+-- context expecting a 'Term' (e.g. 'requireEq'\'s arguments) selects+-- this one.+instance+  forall s rs ci ifs r.+  (HasIndex s rs r) =>+  IsLabel s (Term rs ci ifs r)+  where+  fromLabel = TReg (indexOf @s @rs @r)++-- The @IsLabel s (IndexN s rs r)@ instance lives next to 'IndexN' in+-- "Keiki.Internal.Slots" so the orphan check is satisfied.++-- * Term language ----------------------------------------------------------++-- | A numeric operation carried by 'TArith'. @OpAdd@\/@OpSub@\/@OpMul@+-- are @+@\/@-@\/@*@ respectively. Kept as a single tag (rather than+-- three 'Term' constructors) so each total 'Term' walker switches on+-- one value; the three directions are recovered by the smart+-- constructors 'tadd'\/'tsub'\/'tmul'.+data NumOp = OpAdd | OpSub | OpMul+  deriving stock (Eq, Show)++-- | A pure expression over the register file and the input symbol,+-- yielding a value of type @r@.+--+-- The @ifs :: [Slot]@ parameter is the /input field schema/ this term+-- may project from: it is pinned by 'TInpCtorField' (whose 'Index' is+-- into @ifs@) and left free by terms that do not read an input field+-- ('TLit', 'TReg'). Threading @ifs@ through the AST is what lets an+-- 'OutFields' (and hence an 'OPack') guarantee /by construction/ that+-- every top-level input projection reads the same constructor schema as+-- the 'OPack''s 'InCtor' — so 'solveOutput' recovers a command field+-- with no @unsafeCoerce@. Terms that do not appear in an invertible+-- output position ('Update' right-hand sides, 'HsPred' operands)+-- existentially hide @ifs@, so it never leaks into the 'Edge' /+-- 'SymTransducer' surface. See @docs/research/tinpproj-design.md@.+data Term (rs :: [Slot]) (ci :: Type) (ifs :: [Slot]) (r :: Type) where+  TLit :: r -> Term rs ci ifs r+  TReg :: Index rs r -> Term rs ci ifs r+  -- | Structural input projection: read field @ix@ of the input+  --     constructor described by @ic@. The 'InCtor' value names the+  --     expected constructor and supplies the round-trip+  --     ('icMatch'/'icBuild') so that 'solveOutput' can mechanically+  --     recover @ci@ from an observed output. Pins the term's @ifs@ to the+  --     constructor's field schema. See @docs/research/tinpproj-design.md@.+  TInpCtorField :: InCtor ci ifs -> Index ifs r -> Term rs ci ifs r+  TApp1 ::+    (a -> r) ->+    Term rs ci ifs a ->+    Term rs ci ifs r+  TApp2 ::+    (a -> b -> r) ->+    Term rs ci ifs a ->+    Term rs ci ifs b ->+    Term rs ci ifs r+  -- | Structural arithmetic over a numeric operand type. Unlike the+  --     opaque 'TApp1'\/'TApp2' escape hatches, the SBV translator reads+  --     'TArith' for real (on a 'Keiki.Symbolic.discoverSymNum' hit), so a+  --     guard over a /computed/ value — a weighted sum, a derived cap — is+  --     visible to the solver. The 'Num' constraint prevents constructing+  --     arithmetic at non-numeric operand types; 'Typeable' lets the SBV+  --     translator dispatch on @r@. Build with 'tadd'\/'tsub'\/'tmul'.+  TArith ::+    (Num r, Typeable r) =>+    NumOp ->+    Term rs ci ifs r ->+    Term rs ci ifs r ->+    Term rs ci ifs r++-- | Per-constructor input projection. An 'InCtor' value names one+-- constructor of the input symbol type @ci@ and pins the round-trip+-- between that constructor's payload and a typed register file+-- @'RegFile' ifs@. The slot list @ifs@ is the field schema for the+-- constructor; together with 'Index' it lets call sites read fields+-- via 'OverloadedLabels' (for example @inpStart #email@).+--+-- 'icMatch' must return 'Just' iff @ci@ is the named constructor.+-- 'icBuild' is its left inverse: @icMatch (icBuild rf) == Just rf@ for+-- every well-formed @rf@.+--+-- The constraints 'AssembleRegFile' and 'KnownSlotNames' on the data+-- constructor mean that any code holding an 'InCtor' can both+-- mechanically rebuild a 'RegFile' from a bag of '(Index, value)' pairs+-- and recover the slot names of @ifs@ at run time. The instances are+-- automatic for any concrete slot list, so users do not write any+-- additional code.+--+-- See @docs/research/tinpproj-design.md@ for the design rationale and+-- the inversion algorithm that walks 'OutFields' gathering these+-- per-field reads.+data InCtor ci (ifs :: [Slot]) where+  InCtor ::+    (AssembleRegFile ifs, KnownSlotNames ifs) =>+    { icName :: String,+      icMatch :: ci -> Maybe (RegFile ifs),+      icBuild :: RegFile ifs -> ci+    } ->+    InCtor ci ifs++-- * Slot-list helper classes (v2 inversion machinery) ---------------------++-- | Recover the slot names of an @ifs :: [Slot]@ at run time. Used to+-- print precise hidden-input warnings.+class KnownSlotNames (rs :: [Slot]) where+  slotNames :: [String]++instance KnownSlotNames '[] where+  slotNames = []++instance+  (KnownSymbol s, KnownSlotNames rs) =>+  KnownSlotNames ('(s, r) ': rs)+  where+  slotNames = symbolVal (Proxy @s) : slotNames @rs++-- | An (Index, value) pair indexed by an InCtor's slot list. Using a+-- GADT existential lets us bag entries with different element types+-- under one slot list and unpack them safely via pattern matching on+-- the carried 'Index'.+data ByIndex (ifs :: [Slot]) where+  ByIndex :: Index ifs r -> r -> ByIndex ifs++-- | Class to assemble a 'RegFile' from a bag of '(Index, value)' pairs.+-- 'assemble' returns 'Just' iff every slot of @ifs@ is covered by+-- exactly one entry of the bag (extra entries beyond what slots+-- demand are ignored as long as the per-slot lookups succeed in+-- order).+class AssembleRegFile (ifs :: [Slot]) where+  assemble :: [ByIndex ifs] -> Maybe (RegFile ifs)++instance AssembleRegFile '[] where+  assemble _ = Just RNil++instance+  (KnownSymbol s, AssembleRegFile rs) =>+  AssembleRegFile ('(s, r) ': rs)+  where+  assemble entries = do+    v <- findHead entries+    rest <- assemble (popHead entries)+    pure (RCons (Proxy @s) v rest)+    where+      findHead :: [ByIndex ('(s, r) ': rs)] -> Maybe r+      findHead [] = Nothing+      findHead (ByIndex ZIdx v : _) = Just v+      findHead (_ : rest) = findHead rest++      popHead :: [ByIndex ('(s, r) ': rs)] -> [ByIndex rs]+      popHead [] = []+      popHead (ByIndex ZIdx _ : rest) = popHead rest+      popHead (ByIndex (SIdx i) v : rest) = ByIndex i v : popHead rest++-- * Update language --------------------------------------------------------++-- | The copyless update language. The @(w :: [Symbol])@ index+-- records the set of slot names this update writes; the smart+-- constructor 'combine' demands @'Disjoint' w1 w2@ to combine two+-- updates, so "each register is written at most once per edge+-- update" becomes a type-level invariant rather than a runtime check.+--+-- The 'UCombine' raw constructor is *not* constrained by 'Disjoint':+-- the invariant is enforced at the smart-constructor introduction+-- point ('combine'). This keeps internal pattern-matches in+-- "Keiki.Composition" (which reconstruct 'UCombine' values during+-- weakening / substitution) cheap. EP-18 M8 retired the v1+-- 'unsafeCombine' escape hatch; aggregate authors use 'combine'+-- exclusively.+data Update (rs :: [Slot]) (w :: [Symbol]) (ci :: Type) where+  UKeep :: Update rs '[] ci+  -- The right-hand-side 'Term''s input field schema @ifs@ is+  -- existentially hidden: updates are never inverted, so @ifs@ need not+  -- escape into the 'Update' kind (keeping 'Edge' / 'SymTransducer'+  -- unchanged).+  USet ::+    (KnownSymbol s) =>+    IndexN s rs r -> Term rs ci ifs r -> Update rs '[s] ci+  UCombine ::+    Update rs w1 ci ->+    Update rs w2 ci ->+    Update rs (Concat w1 w2) ci++-- | Smart constructor for 'UCombine'. The @'Disjoint' w1 w2@+-- constraint statically enforces that the two halves write to+-- disjoint slot-name sets; an aggregate that writes the same slot+-- twice (e.g. @'USet' #email t1 \`combine\` 'USet' #email t2@) is+-- rejected at compile time with a 'GHC.TypeError.TypeError' naming+-- the offending slot.+combine ::+  (Disjoint w1 w2) =>+  Update rs w1 ci ->+  Update rs w2 ci ->+  Update rs (Concat w1 w2) ci+combine = UCombine++-- * Output term language ---------------------------------------------------++-- | A wire-type tag for one constructor of the user's output sum @co@.+-- The functions let 'solveOutput' pattern-match an observed @co@ and+-- 'evalOut' rebuild a @co@ from its fields.+data WireCtor co fields = WireCtor+  { wcName :: String,+    wcMatch :: co -> Maybe fields,+    wcBuild :: fields -> co+  }++-- | An HList of 'Term's, one per field of the wire constructor. The+-- field-tuple type @fs@ is built up nested-pair style so that+-- 'solveOutput' can walk the HList structurally.+--+-- The @ifs :: [Slot]@ parameter is the shared input field schema of+-- every 'Term' in the list (see 'Term'). 'OPack' ties it to the+-- 'OPack''s 'InCtor', so a top-level 'TInpCtorField' inside an+-- 'OutFields' is statically an 'Index' into the 'OPack''s constructor+-- schema — 'gatherInpEntries' recovers it with no coercion.+data OutFields rs ci ifs fs where+  OFNil :: OutFields rs ci ifs ()+  OFCons ::+    Term rs ci ifs f ->+    OutFields rs ci ifs fs ->+    OutFields rs ci ifs (f, fs)++-- | Right-associative HList constructor synonym for 'OFCons'. Lets+-- 'OutFields' literals read top-to-bottom in the wire ctor's field+-- order:+--+-- > d.recipient *: d.subject *: d.at *: oNil+--+-- Identical AST: @t1 *: t2 *: oNil@ produces the same 'OutFields'+-- value as @OFCons t1 (OFCons t2 OFNil)@. Available at the AST+-- layer (here) so authors who skip the builder can use it; also+-- re-exported by "Keiki.Builder" for builder-form call sites.+(*:) :: Term rs ci ifs f -> OutFields rs ci ifs fs -> OutFields rs ci ifs (f, fs)+(*:) = OFCons++infixr 5 *:++-- | The empty 'OutFields' HList. Synonym for 'OFNil'.+oNil :: OutFields rs ci ifs ()+oNil = OFNil++-- | A pure expression yielding an output value @co@.+data OutTerm (rs :: [Slot]) (ci :: Type) (co :: Type) where+  -- | Structural pack: tagged by an input constructor (which the edge+  --     consumes) and an output wire constructor (which the edge produces),+  --     with one 'Term' per field of the wire constructor. 'solveOutput'+  --     walks the structural 'OutFields', gathering '(Index, value)' pairs+  --     against the named 'InCtor', and reconstructs the input by calling+  --     'icBuild' on the assembled register file. Empty-payload input+  --     constructors (the 'InCtor's slot list is @\'[]@) recover trivially+  --     as @icBuild ic RNil@.+  OPack ::+    InCtor ci ifs ->+    WireCtor co fields ->+    OutFields rs ci ifs fields ->+    OutTerm rs ci co++-- * Predicate carrier ------------------------------------------------------++-- | The predicate AST. Carries enough structure to evaluate guards and+-- to translate to SMT through the SBV-backed 'BoolAlg' instance in+-- "Keiki.Symbolic" (added in EP-2 of MasterPlan 2).+data HsPred (rs :: [Slot]) (ci :: Type) where+  PTop :: HsPred rs ci+  PBot :: HsPred rs ci+  PAnd :: HsPred rs ci -> HsPred rs ci -> HsPred rs ci+  POr :: HsPred rs ci -> HsPred rs ci -> HsPred rs ci+  PNot :: HsPred rs ci -> HsPred rs ci+  PEq ::+    (Eq r, Typeable r) =>+    Term rs ci ifs1 r -> Term rs ci ifs2 r -> HsPred rs ci+  -- | Structural input-constructor guard: @True@ iff the input symbol+  --     is the constructor named by the carried 'InCtor'. The SBV-backed+  --     'BoolAlg' instance recognises constructor mutual exclusion+  --     symbolically through this constructor. See+  --     @docs/research/sbv-boolalg-design.md@.+  PInCtor :: InCtor ci ifs -> HsPred rs ci+  -- | Structural guard for the outer @Left@ arm of an 'Either' input.+  --     This discriminator is independent of 'PInCtor', so a guard can+  --     constrain both the sum arm and the constructor inside that arm.+  PLeftArm :: HsPred rs (Either ci1 ci2)+  -- | Structural guard for the outer @Right@ arm of an 'Either' input.+  PRightArm :: HsPred rs (Either ci1 ci2)+  -- | Ordering guard: compares two 'Term's of the same orderable type+  --     with the relation named by 'Cmp'. @PCmp CmpGe a b@ means @a >= b@,+  --     and so on. Unlike a threshold written through 'TApp1'\/'TApp2'+  --     (which is opaque to the solver), 'PCmp' is /structural/: the+  --     SBV-backed translator in "Keiki.Symbolic" emits a real symbolic+  --     comparison (@.<@, @.<=@, @.>@, @.>=@) whenever the operand type's+  --     'Keiki.Symbolic.SymRep' is symbolically orderable (see+  --     'Keiki.Symbolic.discoverSymOrd'); otherwise it falls back to a+  --     fresh opaque 'SBool', exactly as 'PEq' does for non-'Sym' operands.+  --     Equality is intentionally left to 'PEq' — 'Cmp' has no "equal"+  --     case. Added by EP-41.+  PCmp ::+    (Ord r, Typeable r) =>+    Cmp -> Term rs ci ifs1 r -> Term rs ci ifs2 r -> HsPred rs ci++-- | A four-way ordering relation carried by 'PCmp'. @Lt@\/@Le@\/@Gt@\/+-- @Ge@ are @<@\/@<=@\/@>@\/@>=@ respectively. Kept as a single tag+-- (rather than four 'HsPred' constructors) so the evaluator and the+-- SBV translator each switch on one value; the four directions are+-- recovered by the builder conveniences+-- 'Keiki.Builder.requireLt'\/'requireLe'\/'requireGt'\/'requireGe'.+data Cmp = CmpLt | CmpLe | CmpGt | CmpGe+  deriving stock (Eq, Show)++-- * Effective Boolean algebra ----------------------------------------------++-- | An effective Boolean algebra over @a@-typed witnesses, used as the+-- guard carrier of edges. Witness /extraction/ ('sat') is a separate,+-- stronger capability — see 'Sat'.+class BoolAlg phi a | phi -> a where+  top :: phi+  bot :: phi+  conj :: phi -> phi -> phi+  disj :: phi -> phi -> phi+  neg :: phi -> phi+  models :: phi -> a -> Bool+  isBot :: phi -> Bool++-- | A 'BoolAlg' whose witnesses can be /extracted/ from a satisfiable+-- predicate: @'sat' phi@ returns 'Just' a value satisfying @phi@, or+-- 'Nothing' when @phi@ is unsatisfiable.+--+-- Split out of 'BoolAlg' by EP-44 (MasterPlan 12). Witness+-- reconstruction needs carrier-specific evidence — for the SBV-backed+-- 'Keiki.Symbolic.SymPred' carrier, @ExtractRegFile rs@ (to rebuild the+-- register file from the solver model) and @KnownInCtors ci@ (to rebuild+-- the command) — that the algebra's build/decide methods do not. Keeping+-- 'sat' in its own class means the witness-free analyses+-- ('Keiki.Symbolic.isSingleValuedSym', which uses only 'isBot'/'conj')+-- carry no extraction constraints, so they keep type-checking on+-- register-file-existential carriers (e.g. 'Keiki.Profunctor.SomeSymTransducer')+-- and on composition-produced @ci@ types ('Either', tuples) that have no+-- 'KnownInCtors'. See @docs/research/sbv-boolalg-design.md@.+class (BoolAlg phi a) => Sat phi a where+  sat :: phi -> Maybe a++instance BoolAlg (HsPred rs ci) (RegFile rs, ci) where+  top = PTop+  bot = PBot+  conj p q = PAnd p q+  disj p q = POr p q+  neg p = PNot p+  models p (regs, ci) = evalPred p regs ci+  isBot PBot = True+  isBot _ = False++-- | The v1 syntactic carrier has no solver, hence no extractable+-- witness; 'sat' is always 'Nothing'. The precise witnesses come from+-- the SBV-backed @Sat (SymPred …)@ instance in "Keiki.Symbolic".+instance Sat (HsPred rs ci) (RegFile rs, ci) where+  sat _ = Nothing++-- * Edges and the transducer -----------------------------------------------++-- | A single transition. The 'output' is a list of 'OutTerm's:+-- @[]@ is the ε-edge (no observable emission), @[o]@ is the letter+-- edge (one event, identical to today's @'Just' o@), @[o1, o2, ...]@+-- is the multi-event edge — one transition emits N events in+-- declaration order. See @docs/research/gsm-widening-design.md@.+--+-- The @(w :: [Symbol])@ index on 'update' (the slot-name set the+-- update writes) is *existentially* quantified at the 'Edge' record+-- — different edges out of the same vertex write different slot+-- sets, but the homogeneous list @[Edge phi rs ci co s]@ in+-- 'edgesOut' demands a single @Edge@ type. The existential preserves+-- the static disjointness check at the *introduction* point of any+-- 'Update' value (via 'combine') without polluting the @Edge@'s+-- public type with a per-edge @w@ parameter.+data Edge phi rs ci co s where+  Edge ::+    { guard :: phi,+      update :: Update rs w ci,+      output :: [OutTerm rs ci co],+      target :: s+    } ->+    Edge phi rs ci co s++-- | The single source of truth: a finite control graph plus a register+-- file evolved by edges' 'update' terms.+data SymTransducer phi rs s ci co = SymTransducer+  { edgesOut :: s -> [Edge phi rs ci co s],+    initial :: s,+    initialRegs :: RegFile rs,+    isFinal :: s -> Bool+  }++-- | Readable alias for the v1 predicate carrier:+-- @'Pred' rs ci@ is exactly @'HsPred' rs ci@.+type Pred rs ci = HsPred rs ci++-- | A 'SymTransducer' whose guard carrier is the v1 'HsPred'. Collapses+-- the @'SymTransducer' ('HsPred' rs ci) rs s ci co@ signature — which+-- otherwise repeats @rs@ and @ci@ — into @'Guarded' rs s ci co@.+type Guarded rs s ci co = SymTransducer (HsPred rs ci) rs s ci co++-- | Apply an edge's update to the register file. The 'Edge''s+-- existentially-quantified @w@ index makes @'update' e@ unusable as+-- a function (GHC rejects with "escaped type variables"); this+-- helper hides the existential by pattern-matching internally.+applyEdgeUpdate ::+  Edge phi rs ci co s -> RegFile rs -> ci -> RegFile rs+applyEdgeUpdate Edge {update = u} regs ci = runUpdate u regs ci++-- | Does an edge's update read the input symbol via 'TInpCtorField'?+-- Existential-hiding companion to 'updateReadsInput'.+edgeReadsInput :: Edge phi rs ci co s -> Bool+edgeReadsInput Edge {update = u} = updateReadsInput u++-- * Helpers (DSL surface) --------------------------------------------------++-- | Structural input-constructor guard: @True@ iff the input symbol+-- is the constructor named by the supplied 'InCtor'. The SBV-backed+-- 'BoolAlg' instance can decide constructor-mutual-exclusion+-- symbolically through this guard. The semantics is+-- @evalPred (matchInCtor ic) regs ci == isJust (icMatch ic ci)@.+matchInCtor :: InCtor ci ifs -> HsPred rs ci+matchInCtor = PInCtor++-- | Read a register slot into a 'Term'.+proj :: Index rs r -> Term rs ci ifs r+proj = TReg++-- | Structural input projection: read field @ix@ of the input+-- constructor described by @ic@. The result 'Term''s @ifs@ is the+-- constructor's field schema, so an 'OutFields' built from these is+-- statically tied to the 'OPack''s 'InCtor'.+inpCtor :: InCtor ci ifs -> Index ifs r -> Term rs ci ifs r+inpCtor = TInpCtorField++-- | A constant 'Term'.+lit :: r -> Term rs ci ifs r+lit = TLit++-- | Structural arithmetic smart constructors. @tadd@\/@tsub@\/@tmul@+-- build a 'TArith' over @+@\/@-@\/@*@. The operand type must be numeric+-- ('Num') and 'Typeable'; the SBV translator reads them structurally+-- (see 'Keiki.Symbolic.discoverSymNum'), unlike the opaque 'TApp'+-- escape hatches.+tadd,+  tsub,+  tmul ::+    (Num r, Typeable r) =>+    Term rs ci ifs r -> Term rs ci ifs r -> Term rs ci ifs r+tadd = TArith OpAdd+tsub = TArith OpSub+tmul = TArith OpMul++-- | Equality predicate sugar.+(.==) :: (Eq r, Typeable r) => Term rs ci ifs1 r -> Term rs ci ifs2 r -> HsPred rs ci+(.==) = PEq++infix 4 .==++-- * Predicate & term operators (readable guard DSL) ----------------------++-- | Ordering-guard operators. Each is an alias for 'PCmp' at a fixed+-- 'Cmp': @a .>= b@ is @'PCmp' 'CmpGe' a b@ (i.e. @a >= b@); @a .< b@ is+-- @'PCmp' 'CmpLt' a b@; and so on. Same fixity as '(.==)' (@infix 4@):+-- relational operators do not chain, sit below the arithmetic operators+-- ('.+'/'.-'/'.*'), and above the logical ones ('.&&'/'.||').+(.<),+  (.<=),+  (.>),+  (.>=) ::+    (Ord r, Typeable r) => Term rs ci ifs1 r -> Term rs ci ifs2 r -> HsPred rs ci+(.<) = PCmp CmpLt+(.<=) = PCmp CmpLe+(.>) = PCmp CmpGt+(.>=) = PCmp CmpGe++infix 4 .<, .<=, .>, .>=++-- | Inequality guard. @a ./= b@ is @'pnot' (a '.==' b)@, i.e.+-- @'PNot' ('PEq' a b)@. Mirrors 'Prelude.(/=)' against the existing+-- '(.==)'.+(./=) :: (Eq r, Typeable r) => Term rs ci ifs1 r -> Term rs ci ifs2 r -> HsPred rs ci+a ./= b = PNot (PEq a b)++infix 4 ./=++-- | Conjunction / disjunction of predicates. Aliases for 'PAnd' / 'POr',+-- mirroring 'Prelude.(&&)' / 'Prelude.(||)' in fixity (@infixr 3@ /+-- @infixr 2@), so @p .&& q .|| r@ parses as @(p .&& q) .|| r@.+(.&&), (.||) :: HsPred rs ci -> HsPred rs ci -> HsPred rs ci+(.&&) = PAnd+(.||) = POr++infixr 3 .&&++infixr 2 .||++-- | Predicate negation. Alias for 'PNot'. ('Keiki.Core.BoolAlg' also+-- exposes 'neg', which is this same operation lifted through the class;+-- 'pnot' is the direct AST alias for hand-written guards.)+pnot :: HsPred rs ci -> HsPred rs ci+pnot = PNot++-- | Structural arithmetic operators on 'Term's. Aliases for+-- 'tadd' / 'tsub' / 'tmul', mirroring 'Prelude.(+)' / '(-)' / '(*)' in+-- fixity (@infixl 6@ / @infixl 6@ / @infixl 7@). Because they build the+-- structural 'TArith' node (not an opaque 'TApp'), arithmetic written+-- with them is visible to the SBV translator in "Keiki.Symbolic".+(.+),+  (.-),+  (.*) ::+    (Num r, Typeable r) => Term rs ci ifs r -> Term rs ci ifs r -> Term rs ci ifs r+(.+) = tadd+(.-) = tsub+(.*) = tmul++infixl 6 .+, .-++infixl 7 .*++-- | Structural-output construction. 'solveOutput' inverts the result+-- mechanically by walking 'OutFields' against the named input+-- constructor; users no longer supply an inverse function. The+-- 'InCtor' first argument names the @ci@ constructor the edge expects;+-- it makes recovery work even for edges whose input has no payload+-- (e.g. a singleton 'Continue' command).+pack ::+  InCtor ci ifs ->+  WireCtor co fields ->+  OutFields rs ci ifs fields ->+  OutTerm rs ci co+pack = OPack++-- * Evaluators -------------------------------------------------------------++-- | Evaluate a 'Term' against a register file and an input symbol.+evalTerm :: Term rs ci ifs r -> RegFile rs -> ci -> r+evalTerm (TLit r) _ _ = r+evalTerm (TReg ix) regs _ = regs ! ix+evalTerm (TInpCtorField ic ix) _ ci = case icMatch ic ci of+  Just rf -> rf ! ix+  Nothing -> error ("evalTerm: TInpCtorField guard violation: " ++ icName ic)+evalTerm (TApp1 f t) regs ci = f (evalTerm t regs ci)+evalTerm (TApp2 f a b) regs ci = f (evalTerm a regs ci) (evalTerm b regs ci)+evalTerm (TArith op a b) regs ci =+  applyNumOp op (evalTerm a regs ci) (evalTerm b regs ci)++-- | Interpret a 'NumOp' tag as the corresponding numeric operation.+-- The 'Num' evidence is supplied by matching the 'TArith' constructor.+applyNumOp :: (Num r) => NumOp -> r -> r -> r+applyNumOp OpAdd = (+)+applyNumOp OpSub = (-)+applyNumOp OpMul = (*)++-- | Evaluate an 'OutTerm' against a register file and an input symbol.+-- The 'InCtor' on 'OPack' is consulted only by the inverse direction+-- ('solveOutput'); evaluation just runs the wire build over the+-- evaluated 'OutFields'.+evalOut :: OutTerm rs ci co -> RegFile rs -> ci -> co+evalOut (OPack _ic ctor fields) regs ci =+  wcBuild ctor (evalOutFields fields regs ci)++evalOutFields :: OutFields rs ci ifs fs -> RegFile rs -> ci -> fs+evalOutFields OFNil _ _ = ()+evalOutFields (OFCons t rest) regs ci =+  (evalTerm t regs ci, evalOutFields rest regs ci)++-- | Evaluate a predicate to a 'Bool' on the current state.+evalPred :: HsPred rs ci -> RegFile rs -> ci -> Bool+evalPred PTop _ _ = True+evalPred PBot _ _ = False+evalPred (PAnd p q) r c = evalPred p r c && evalPred q r c+evalPred (POr p q) r c = evalPred p r c || evalPred q r c+evalPred (PNot p) r c = not (evalPred p r c)+evalPred (PEq a b) r c = evalTerm a r c == evalTerm b r c+evalPred (PInCtor ic) _ c = case icMatch ic c of+  Just _ -> True+  Nothing -> False+evalPred PLeftArm _ input = case input of+  Left _ -> True+  Right _ -> False+evalPred PRightArm _ input = case input of+  Left _ -> False+  Right _ -> True+evalPred (PCmp op a b) r c = applyCmp op (evalTerm a r c) (evalTerm b r c)+  where+    applyCmp :: (Ord x) => Cmp -> x -> x -> Bool+    applyCmp CmpLt x y = x < y+    applyCmp CmpLe x y = x <= y+    applyCmp CmpGt x y = x > y+    applyCmp CmpGe x y = x >= y++-- | Apply an 'Update' to the register file. Every right-hand-side term+-- reads the register snapshot from before the whole update began. Writes+-- are then applied left-to-right; the public 'combine' constructor requires+-- disjoint slots, while internal raw 'UCombine' values that repeat a slot+-- therefore keep the rightmost write.+runUpdate :: forall rs w ci. Update rs w ci -> RegFile rs -> ci -> RegFile rs+runUpdate update regs ci = go update regs+  where+    go :: Update rs w' ci -> RegFile rs -> RegFile rs+    go UKeep acc = acc+    go (USet ix t) acc = setSlotN ix (evalTerm t regs ci) acc+    go (UCombine a b) acc = go b (go a acc)++-- | Pure register-file slot update at a slot-name-tagged 'IndexN'.+--+-- The bang-pattern on @v@ forces the new slot value to WHNF before+-- threading it into the rebuilt 'RCons'. Without this, every+-- 'runUpdate' / 'step' cycle in a long-running embedder accumulates+-- a tower of thunks at the written slot, which is exactly the failure+-- mode the @NoThunks (RegFile rs)@ instance ("Keiki.NoThunks") was+-- introduced to detect (EP-23). Untouched slots retain whatever+-- WHNF status they already had, which preserves+-- 'Keiki.Generics.emptyRegFile'\'s targeted @uninit:@ sentinels for+-- slots that have never been written.+setSlotN :: IndexN s rs r -> r -> RegFile rs -> RegFile rs+setSlotN IZ !v regs = case regs of RCons p _ rest -> RCons p v rest+setSlotN (IS i) !v regs = case regs of+  RCons p x rest ->+    let !rest' = setSlotN i v rest+     in RCons p x rest'++-- | Single-step transition. Returns 'Just (s', regs')' iff exactly one+-- outgoing edge has a satisfied guard.+delta ::+  (BoolAlg phi (RegFile rs, ci)) =>+  SymTransducer phi rs s ci co ->+  s ->+  RegFile rs ->+  ci ->+  Maybe (s, RegFile rs)+delta t s regs ci =+  case [ (target e, applyEdgeUpdate e regs ci)+       | e <- edgesOut t s,+         models (guard e) (regs, ci)+       ] of+    [single] -> Just single+    _ -> Nothing++-- | Single-step output. Returns the list of events emitted by the+-- unique active edge: @[]@ for an ε-edge, @[o]@ for a letter edge,+-- @[o1, o2, ...]@ for a multi-event edge. Returns @[]@ if no edge+-- is active or if more than one edge is active. Therefore @[]@ conflates+-- three different outcomes: a rejected command, an ambiguous command,+-- and an accepted ε-edge that emits nothing. Never interpret @[]@ as+-- proof that a command was rejected; use 'stepEither' to distinguish all+-- three outcomes with structured failures.+omega ::+  (BoolAlg phi (RegFile rs, ci)) =>+  SymTransducer phi rs s ci co ->+  s ->+  RegFile rs ->+  ci ->+  [co]+omega t s regs ci =+  case [ [evalOut o regs ci | o <- output e]+       | e <- edgesOut t s,+         models (guard e) (regs, ci)+       ] of+    [evaluatedOuts] -> evaluatedOuts+    _ -> []++-- * Pure-layer entry points ------------------------------------------------++-- | One full step of the transducer combining 'delta' and 'omega'.+-- Returns 'Nothing' if no edge from the current vertex has a satisfied+-- guard. The inner @[co]@ is @[]@ for an ε-edge, @[o]@ for a letter+-- edge, @[o1, o2, ...]@ for a multi-event edge.+step ::+  (BoolAlg phi (RegFile rs, ci)) =>+  SymTransducer phi rs s ci co ->+  (s, RegFile rs) ->+  ci ->+  Maybe (s, RegFile rs, [co])+step t (s, regs) ci = case delta t s regs ci of+  Nothing -> Nothing+  Just (s', regs') -> Just (s', regs', omega t s regs ci)++-- | A locator for one outgoing edge: the vertex it leaves from and its+-- zero-based position in @'edgesOut' t source@. This is the canonical+-- edge-identity vocabulary shared with build-time diagnostics (EP-56).+data EdgeRef s = EdgeRef+  { edgeSource :: s,+    edgeIndex :: Int+  }+  deriving stock (Eq, Show)++-- | Why one outgoing edge was rejected during a step: its locator, its+-- declared target, and whether its guard matched (always 'False' here;+-- the field keeps the shape uniform with 'MatchedEdgeSummary' and leaves+-- room for richer rejection reasons later). Deliberately carries NO+-- register values — diagnostics summarize, they do not dump state.+data RejectedEdgeSummary s = RejectedEdgeSummary+  { rejectedEdge :: EdgeRef s,+    rejectedTarget :: s,+    rejectedGuard :: Bool+  }+  deriving stock (Eq, Show)++-- | One outgoing edge whose guard matched during a step: its locator and+-- its declared target. Carries NO register values.+data MatchedEdgeSummary s = MatchedEdgeSummary+  { matchedEdge :: EdgeRef s,+    matchedTarget :: s+  }+  deriving stock (Eq, Show)++-- | A precise explanation of why a step could not advance.+--+--   * 'NoOutgoingEdges' — the source vertex has no outgoing edges at all.+--   * 'NoMatchingEdge'   — there are outgoing edges, but none matched the+--     command; carries one 'RejectedEdgeSummary' per edge, in declaration+--     order.+--   * 'AmbiguousEdges'   — two or more guards matched the same command, a+--     runtime witness of a single-valuedness violation (the property+--     EP-56's 'checkTransitionDeterminism' proves statically); carries one+--     'MatchedEdgeSummary' per matched edge.+data StepFailure s+  = NoOutgoingEdges s+  | NoMatchingEdge s [RejectedEdgeSummary s]+  | AmbiguousEdges s [MatchedEdgeSummary s]+  deriving stock (Eq, Show)++-- | Like 'step', but returns a precise 'StepFailure' explanation on the+-- 'Left' instead of collapsing every failure into 'Nothing'. On the+-- 'Right' it returns EXACTLY the triple 'step' returns. 'step' is left+-- unchanged; this is purely additive.+stepEither ::+  (BoolAlg phi (RegFile rs, ci)) =>+  SymTransducer phi rs s ci co ->+  (s, RegFile rs) ->+  ci ->+  Either (StepFailure s) (s, RegFile rs, [co])+stepEither t (s, regs) ci =+  case zip [0 ..] (edgesOut t s) of+    [] -> Left (NoOutgoingEdges s)+    indexed ->+      let matched =+            [ (i, e)+            | (i, e) <- indexed,+              models (guard e) (regs, ci)+            ]+       in case matched of+            [] ->+              Left $+                NoMatchingEdge+                  s+                  [ RejectedEdgeSummary+                      { rejectedEdge = EdgeRef {edgeSource = s, edgeIndex = i},+                        rejectedTarget = target e,+                        rejectedGuard = False+                      }+                  | (i, e) <- indexed+                  ]+            [(_, e)] ->+              let !regs' = applyEdgeUpdate e regs ci+                  outs = [evalOut o regs ci | o <- output e]+               in Right (target e, regs', outs)+            _ ->+              Left $+                AmbiguousEdges+                  s+                  [ MatchedEdgeSummary+                      { matchedEdge = EdgeRef {edgeSource = s, edgeIndex = i},+                        matchedTarget = target e+                      }+                  | (i, e) <- matched+                  ]++-- | Apply one observed output to the state by walking outgoing edges,+-- inverting each edge's @output@ via 'solveOutput', verifying the+-- guard on the recovered input, and applying the edge's @update@.+-- Exposed for callers that deliberately model letter-only event streams;+-- full-log replay uses the InFlight-aware streaming surface instead.+--+-- == Letter-only semantics+--+-- This function handles ε-edges (@output = []@; skipped because they+-- emit nothing observable) and letter edges (@output = [o]@;+-- inverted via 'solveOutput'). For multi-event edges (@output =+-- [o1, ..., oN]@ with N >= 2), this letter-flavoured 'applyEvent'+-- only inverts against the *head* of the output list, returning the+-- target vertex on a successful match. It is suitable when the+-- caller knows it is replaying letter-only events; for true+-- streaming replay across multi-event edges (where intermediate+-- events in the chain must be matched against the expected tail of+-- a prior edge's output list) use 'applyEventStreaming'.+applyEvent ::+  (BoolAlg phi (RegFile rs, ci), Eq co) =>+  SymTransducer phi rs s ci co ->+  s ->+  RegFile rs ->+  co ->+  Maybe (s, RegFile rs)+applyEvent t s regs co =+  case [ (target e, applyEdgeUpdate e regs ci)+       | e <- edgesOut t s,+         o : _ <- [output e],+         Just ci <- [solveOutput o regs co],+         models (guard e) (regs, ci)+       ] of+    [single] -> Just single+    _ -> Nothing++-- | Streaming-replay state wrapper. Used by 'applyEventStreamingEither'+-- and its 'applyEventStreaming' compatibility wrapper.+--+-- @'Settled' s@ is the state at a stable vertex — the next event+-- must be the first emission of /some/ outgoing edge of @s@.+--+-- @'InFlight' s [e2, ..., eN]@ is the mid-chain state at vertex+-- @s@ (the *target* of the in-flight chain's edge; register updates+-- have already been applied at the transition into 'InFlight'). The+-- queue holds the *evaluated* expected events in order; the next+-- observed event must equal the head, popping it; when the queue+-- empties, the wrapper transitions to @'Settled' s@.+--+-- See @docs/research/gsm-widening-design.md@ §4 for the formal+-- treatment and a worked example on the @StartRegistration@ chain.+data InFlight s co+  = Settled !s+  | InFlight !s ![co]+  deriving (Eq, Show)++-- | Why one observed event could not be replayed. Mirrors 'StepFailure'+-- and deliberately carries NO register values: diagnostics summarize,+-- they do not dump state. Events are carried where they identify the+-- failure because the event log is already observable data.+--+-- 'ReplayNoInvertingEdge' carries one rejected summary per outgoing+-- edge in declaration order, including edges with no output. An empty+-- list means the vertex had no outgoing edges. 'rejectedGuard' is+-- currently always 'False', matching 'NoMatchingEdge'.+data ReplayStepFailure s co+  = ReplayNoInvertingEdge s [RejectedEdgeSummary s]+  | ReplayAmbiguousInversions s [MatchedEdgeSummary s]+  | ReplayQueueMismatch s co [co]+  deriving stock (Eq, Show)++-- | Why replaying a list of observed events failed. A step failure+-- identifies an event that could not be consumed; truncation means the+-- input ended while a multi-event output chain still had pending events.+data ReplayFailureReason s co+  = ReplayEventFailed (ReplayStepFailure s co)+  | ReplayLogTruncated [co]+  deriving stock (Eq, Show)++-- | A list-level replay failure with its zero-based input position and+-- wrapper state immediately before the failure. For 'ReplayLogTruncated',+-- 'replayFailedIndex' is the input length: the position where the next+-- expected event was missing.+data ReplayFailure s co = ReplayFailure+  { replayFailedIndex :: Int,+    replayFailedState :: InFlight s co,+    replayFailureReason :: ReplayFailureReason s co+  }+  deriving stock (Eq, Show)++-- | Compatibility wrapper around 'applyEventStreamingEither'. Prefer the+-- structured 'Either' variant for new code; this function preserves the+-- historical @Maybe@ signature and semantics.+--+-- Apply one observed output to a streaming-replay state. Two arms:+--+--   1. @'Settled' s@ — walk outgoing edges of @s@; find the unique+--      edge whose @output@'s *head* inverts to a valid @ci@ via+--      'solveOutput' satisfying the guard. Commit to that edge, run+--      its update, evaluate the *tail* of the output list against+--      the recovered @(regs, ci)@ snapshot. If the tail is empty+--      (letter edge), return @('Settled' (target e), regs')@. If the+--      tail is non-empty (multi-event edge), return @('InFlight'+--      (target e) tail, regs')@.+--+--   2. @'InFlight' s (q1 : rest) regs@ — equality-check @q1@+--      against the observed event. On match, advance the queue+--      (returning @'Settled' s@ when @rest == []@, otherwise+--      @'InFlight' s rest@). No register update — registers were+--      updated at the @Settled → InFlight@ transition. On mismatch+--      (out-of-order replay) return 'Nothing'.+--+-- The 'Eq' constraint on @co@ supports the queue equality check.+-- Most aggregate event types derive 'Eq' (a documented expectation+-- of the foundations).+--+-- Inversion is deliberately head-only: 'solveOutput' is applied to the first+-- 'OutTerm' before the tail queue exists, and later events are equality-checked+-- only. Tail events therefore cannot supply command fields missing from the+-- head. 'validateTransducer' reports that authoring error as+-- 'HeadUnrecoverable'.+applyEventStreaming ::+  (BoolAlg phi (RegFile rs, ci), Eq co) =>+  SymTransducer phi rs s ci co ->+  InFlight s co ->+  RegFile rs ->+  co ->+  Maybe (InFlight s co, RegFile rs)+applyEventStreaming t wrapper regs co =+  either (const Nothing) Just (applyEventStreamingEither t wrapper regs co)++-- | Apply one observed event to a streaming-replay state, returning a+-- structured explanation when replay cannot advance.+applyEventStreamingEither ::+  (BoolAlg phi (RegFile rs, ci), Eq co) =>+  SymTransducer phi rs s ci co ->+  InFlight s co ->+  RegFile rs ->+  co ->+  Either (ReplayStepFailure s co) (InFlight s co, RegFile rs)+applyEventStreamingEither t (Settled s) regs co =+  case matched of+    [] ->+      Left $+        ReplayNoInvertingEdge+          s+          [ RejectedEdgeSummary+              { rejectedEdge = EdgeRef {edgeSource = s, edgeIndex = i},+                rejectedTarget = target e,+                rejectedGuard = False+              }+          | (i, e) <- indexed+          ]+    [(_, e, ci)] ->+      let regs' = applyEdgeUpdate e regs ci+          evaluatedTail = [evalOut o regs ci | o <- drop 1 (output e)]+          wrapped = case evaluatedTail of+            [] -> Settled (target e)+            xs -> InFlight (target e) xs+       in Right (wrapped, regs')+    _ ->+      Left $+        ReplayAmbiguousInversions+          s+          [ MatchedEdgeSummary+              { matchedEdge = EdgeRef {edgeSource = s, edgeIndex = i},+                matchedTarget = target e+              }+          | (i, e, _) <- matched+          ]+  where+    indexed = zip [0 ..] (edgesOut t s)+    matched =+      [ (i, e, ci)+      | (i, e) <- indexed,+        o : _ <- [output e],+        Just ci <- [solveOutput o regs co],+        models (guard e) (regs, ci)+      ]+applyEventStreamingEither _ (InFlight s queue) regs co = case queue of+  q1 : rest+    | q1 == co ->+        Right+          ( case rest of+              [] -> Settled s+              _ -> InFlight s rest,+            regs+          )+  _ -> Left (ReplayQueueMismatch s co queue)++-- | Strictly replay a list of observed events from an arbitrary wrapper+-- state and register-file seed. A list that ends mid-chain succeeds with+-- a final 'InFlight' wrapper so paginated callers can resume with the next+-- page. Use 'applyEventsEither' when ending mid-chain must be an error.+replayEvents ::+  (BoolAlg phi (RegFile rs, ci), Eq co) =>+  SymTransducer phi rs s ci co ->+  (InFlight s co, RegFile rs) ->+  [co] ->+  Either (ReplayFailure s co) (InFlight s co, RegFile rs)+replayEvents t = go 0+  where+    go !_ seed [] = Right seed+    go !eventIndex (wrapper, regs) (co : rest) =+      case applyEventStreamingEither t wrapper regs co of+        Left stepFailure ->+          Left+            ReplayFailure+              { replayFailedIndex = eventIndex,+                replayFailedState = wrapper,+                replayFailureReason = ReplayEventFailed stepFailure+              }+        Right next -> go (eventIndex + 1) next rest++-- | Compatibility wrapper around 'reconstituteEither'. Prefer the+-- structured 'Either' variant for new code. This function reconstitutes+-- @(state, registers)@ from a log of outputs while preserving the+-- historical @Maybe@ signature and semantics.+--+-- For letter-only transducers (every edge has @output@ of length 0+-- or 1) the streaming wrapper is always 'Settled' and the result is+-- identical to the pre-EP-19 letter-fold. A log that ends mid-chain+-- through a multi-event edge returns 'Nothing' — there is no valid+-- @(s, regs)@ to surface from an 'InFlight' final state.+reconstitute ::+  (BoolAlg phi (RegFile rs, ci), Eq co) =>+  SymTransducer phi rs s ci co ->+  [co] ->+  Maybe (s, RegFile rs)+reconstitute t events =+  either (const Nothing) Just (reconstituteEither t events)++-- | Reconstitute @(state, registers)@ from the transducer's initial+-- state, returning the exact event index, wrapper state, and structured+-- reason when replay fails.+reconstituteEither ::+  (BoolAlg phi (RegFile rs, ci), Eq co) =>+  SymTransducer phi rs s ci co ->+  [co] ->+  Either (ReplayFailure s co) (s, RegFile rs)+reconstituteEither t = applyEventsEither t (initial t, initialRegs t)++-- | Compatibility wrapper around 'applyEventsEither'. Prefer the+-- structured 'Either' variant for new code. This function replays a+-- chunk of events from a caller-supplied @(state, registers)@ start while+-- preserving the historical @Maybe@ signature and semantics.+--+-- Useful when the runtime preserves command boundaries (event store+-- with command-id tags, transactional batches, deterministic test+-- fixtures): replay one command's events as one atomic step and+-- consume the unwrapped final state.+--+-- == Multi-event edges (EP-19 M3)+--+-- Internally, the implementation lifts the start state to 'Settled'+-- and folds 'applyEventStreaming' over the chunk; the wrapper+-- transitions through 'InFlight' for multi-event edges and unwraps+-- back to 'Settled' when the chunk completes. A chunk that ends+-- mid-flight (the queue is non-empty at the end of the input list)+-- returns 'Nothing'; this signals a truncated chunk relative to the+-- edge's static output length.+--+-- For length-0/1 edges the behaviour is identical to the legacy+-- letter-fold; for length-2+ edges the chunk must contain the full+-- expected sequence of evaluated events in order.+--+-- Returns 'Nothing' if any event in the chunk fails to replay (e.g.+-- a malformed log, an event that does not match any active edge's+-- output at the current vertex, or a chunk that ends mid-flight).+applyEvents ::+  (BoolAlg phi (RegFile rs, ci), Eq co) =>+  SymTransducer phi rs s ci co ->+  (s, RegFile rs) ->+  [co] ->+  Maybe (s, RegFile rs)+applyEvents t seed events =+  either (const Nothing) Just (applyEventsEither t seed events)++-- | Replay a complete chunk from a caller-supplied settled state. Unlike+-- 'replayEvents', this strict facade rejects a chunk that ends while a+-- multi-event output chain still has pending events.+applyEventsEither ::+  (BoolAlg phi (RegFile rs, ci), Eq co) =>+  SymTransducer phi rs s ci co ->+  (s, RegFile rs) ->+  [co] ->+  Either (ReplayFailure s co) (s, RegFile rs)+applyEventsEither t (s0, regs0) events =+  case replayEvents t (Settled s0, regs0) events of+    Left failure -> Left failure+    Right (Settled s, regs) -> Right (s, regs)+    Right (wrapper@(InFlight _ pending), _regs) ->+      Left+        ReplayFailure+          { replayFailedIndex = length events,+            replayFailedState = wrapper,+            replayFailureReason = ReplayLogTruncated pending+          }++-- * Build-time analyses ----------------------------------------------------++-- | Recover the input that produced a given output by walking+-- 'OutFields' structurally against the input constructor named by the+-- 'OPack'. Gather '(Index, value)' pairs from every top-level+-- 'TInpCtorField' read whose 'InCtor' matches by 'icName'; assemble a+-- 'RegFile' covering every slot of the 'InCtor'; call 'icBuild'.+--+-- == Recompute-and-verify (EP-47)+--+-- The command is recovered from the /invertible/ fields alone+-- (@TLit@\/@TReg@\/@TInpCtorField@); /derived/ fields (@TArith@\/@TApp1@\/+-- @TApp2@) are skipped during recovery by 'gatherInpEntries'. After the+-- command is rebuilt, the observed field tuple is rebuilt with each+-- /derived/ field recomputed forward (via 'recomputeDerivedFields') and+-- the resulting event is required to equal the observed event, so each+-- derived field is /verified/ rather than trusted — a tampered derived+-- value is rejected. Invertible fields are kept at their observed values+-- and are /not/ re-verified (so a @TReg@ audit field still round-trips+-- even when replay starts from a state whose registers are not yet+-- populated). This generalizes, at field granularity, the+-- forward-recompute-and-@Eq@-match that 'applyEventStreaming' already does+-- for multi-event tails (see @docs/research/recompute-and-verify-derived-outputs.md@).+--+-- For an all-invertible edge no field is recomputed, so the rebuilt event+-- equals the observed event by construction (the check is a no-op) and the+-- result is identical to the pre-EP-47 behavior. The build-time net+-- 'checkHiddenInputs' still rejects a schema whose command slot is read+-- only inside a derived field (a hidden input), so the command remains+-- recoverable from invertible fields alone — "the event determines the+-- command" is preserved.+--+-- Streaming replay calls this function only for an edge's head 'OutTerm'. It+-- never combines evidence from later outputs, so every command field needed by+-- replay must be recoverable here; 'HeadUnrecoverable' diagnoses a field found+-- only in the tail.+solveOutput :: (Eq co) => OutTerm rs ci co -> RegFile rs -> co -> Maybe ci+solveOutput (OPack ic@InCtor {} ctor fields) regs co = do+  fs_obs <- wcMatch ctor co+  entries <- gatherInpEntries fields fs_obs ic+  rf <- assemble entries+  let ci = icBuild ic rf+      -- Rebuild the observed field tuple, recomputing ONLY the derived+      -- fields (TApp/TArith) forward; invertible fields keep their observed+      -- value. Comparing the rebuilt event to the observed one then verifies+      -- exactly the derived fields — never the invertible ones, so a+      -- register-read audit field is not re-checked against state and the+      -- command thunk is not forced for an all-invertible edge.+      rebuilt = wcBuild ctor (recomputeDerivedFields fields fs_obs regs ci)+  if rebuilt == co+    then Just ci+    else Nothing++-- | Rebuild an observed output-field tuple, recomputing each /derived/+-- field ('TApp1'\/'TApp2'\/'TArith') forward via 'evalTerm' against the+-- recovered command and the pre-update registers, while leaving every+-- /invertible/ field ('TLit'\/'TReg'\/'TInpCtorField') at its observed+-- value. Used by 'solveOutput' (EP-47 recompute-and-verify): comparing the+-- rebuilt event to the observed one (via 'Eq' on @co@) then verifies+-- exactly the derived fields. Invertible fields are deliberately /not/+-- recomputed, so (a) a register-read audit field is not re-verified against+-- the current register file — preserving the "@TReg@ round-trips" contract+-- even when replay starts from a state whose registers are not yet+-- populated — and (b) the recovered-command thunk is not forced for an+-- all-invertible edge.+recomputeDerivedFields ::+  forall rs ci ifs fs. OutFields rs ci ifs fs -> fs -> RegFile rs -> ci -> fs+recomputeDerivedFields OFNil () _ _ = ()+recomputeDerivedFields (OFCons t rest) (v, vs) regs ci =+  (recomputeOne t v, recomputeDerivedFields rest vs regs ci)+  where+    recomputeOne :: forall f. Term rs ci ifs f -> f -> f+    recomputeOne term@(TApp1 _ _) _observed = evalTerm term regs ci+    recomputeOne term@(TApp2 _ _ _) _observed = evalTerm term regs ci+    recomputeOne term@(TArith _ _ _) _observed = evalTerm term regs ci+    recomputeOne _ observed = observed++-- | Walk an 'OutFields' HList in lockstep with an observed-fields+-- tuple, gathering '(Index, value)' pairs for the named 'InCtor' from+-- the /invertible/ fields. 'TLit'\/'TReg' contribute nothing; a+-- 'TInpCtorField' for the matching 'InCtor' contributes its+-- '(Index, value)' pair. Since EP-47 the /derived/ fields+-- ('TArith'\/'TApp1'\/'TApp2') are /skipped/ (they contribute no+-- entries) rather than aborting the walk — 'solveOutput' verifies them+-- forward afterwards. Returns 'Nothing' only on a genuinely malformed+-- edge: a 'TInpCtorField' naming a /different/ 'InCtor' (a runtime+-- diagnostic; soundness no longer depends on it — see below). 'assemble+-- []' for an empty 'ifs' is 'Just RNil', so empty-payload input+-- constructors recover trivially; and if a derived field is the /only/+-- place a command slot is read, the skipped slot leaves 'assemble'+-- short and 'solveOutput' fails — exactly the hidden-input case that+-- 'checkHiddenInputs' flags at build time.+--+-- == Type-safe index recovery (EP-53)+--+-- Because 'OutFields' is indexed by the same input field schema @ifs@ as+-- the 'OPack''s 'InCtor', a top-level 'TInpCtorField' inside this+-- 'OutFields' carries an @'Index' ifs r@ /into the @OPack@'s schema by+-- construction/. So @'ByIndex' ix val@ type-checks directly — no+-- @unsafeCoerce@ — and a constructor whose field schema differs from the+-- 'OPack''s 'InCtor' is rejected at compile time rather than coerced at+-- run time. The @'icName' ic1 == 'icName' ic2@ guard is retained only as+-- a defensive runtime diagnostic for an 'OutFields' that names a+-- different (but same-schema) constructor.+gatherInpEntries ::+  forall rs ci ifs fs.+  OutFields rs ci ifs fs -> fs -> InCtor ci ifs -> Maybe [ByIndex ifs]+gatherInpEntries OFNil () _ic = Just []+gatherInpEntries (OFCons t rest) (v, fs) ic = do+  here <- stepOne t v ic+  more <- gatherInpEntries rest fs ic+  pure (here ++ more)+  where+    stepOne :: forall f. Term rs ci ifs f -> f -> InCtor ci ifs -> Maybe [ByIndex ifs]+    stepOne (TLit _) _val _ = Just []+    stepOne (TReg _) _val _ = Just []+    stepOne (TInpCtorField ic2 ix) val ic1+      | icName ic1 == icName ic2 = Just [ByIndex ix val]+      | otherwise = Nothing+    -- Derived fields are skipped here and verified forward by+    -- 'solveOutput' (EP-47 recompute-and-verify); they contribute no+    -- command information of their own.+    stepOne (TApp1 _ _) _val _ = Just []+    stepOne (TApp2 _ _ _) _val _ = Just []+    stepOne (TArith _ _ _) _val _ = Just []++-- | A diagnostic produced by 'checkHiddenInputs'.+data HiddenInputWarning = HiddenInputWarning+  { -- | Description of the edge's source (typically @show s@).+    hiwEdgeSource :: String,+    -- | Human-readable description of what's hidden.+    hiwReason :: String+  }+  deriving (Eq, Show)++-- | For every edge in the transducer, check whether the @output@ can+-- mechanically recover the input on replay. Specifically:+--+--   * If @output@ is @[]@ (an ε-edge), and @update@ reads the input+--     symbol, that contribution is silent on the wire and+--     unrecoverable.+--   * If @output@ is non-empty, the per-edge check distinguishes data+--     absent from the entire output union ('HirUnionMiss') from data+--     present only after the head event ('HirHeadUnrecoverable'). Replay+--     cannot use tail-only data because 'applyEventStreaming' inverts only+--     the head and equality-checks the tail.+--+-- For length-1 edges this matches the legacy per-'OPack' check. For+-- length-2+ edges, union coverage is retained only to classify the fix:+-- add absent data to the output, or move tail-only data into the head.+--+-- The check is intentionally conservative: it flags candidates for+-- the author to inspect, not theorems.+checkHiddenInputs ::+  forall phi rs s ci co.+  (Bounded s, Enum s, Show s) =>+  SymTransducer phi rs s ci co ->+  [HiddenInputWarning]+checkHiddenInputs t =+  [ HiddenInputWarning+      { hiwEdgeSource = show s,+        hiwReason = formatHiddenInputReason n r+      }+  | s <- [minBound .. maxBound],+    (n, e) <- zip [(0 :: Int) ..] (edgesOut t s),+    r <- hiddenInputReasons e+  ]++-- | A structured reason an edge's output cannot mechanically recover its+-- input on replay. This is the single source of truth behind both+-- 'checkHiddenInputs' (which formats these into the legacy 'HiddenInputWarning'+-- strings via 'formatHiddenInputReason') and 'hiddenInputWarnings' (which lifts+-- each into a structured 'TransducerValidationWarning' carrying the typed source+-- vertex, the input-constructor name, and the missing slot names).+data HiddenInputReason+  = -- | An ε-edge (empty @output@) whose @update@ reads the input symbol,+    --       so the read information is silent on the wire.+    HirEpsilonReadsInput+  | -- | The named input constructor has declared slots the edge's output+    --       never recovers (after unioning every same-constructor 'OPack').+    --       Carries the constructor name and the missing slot names.+    HirUnionMiss String [String]+  | -- | The named input constructor's slots appear in the output union but+    --       not in the head event, so replay cannot reach them. Carries the+    --       constructor name and the tail-only slot names.+    HirHeadUnrecoverable String [String]+  deriving (Eq, Show)++-- | The per-edge hidden-input analysis, factored out of 'checkHiddenInputs'+-- so the legacy string warnings and the structured 'hiddenInputWarnings' share+-- one implementation. For an ε-edge it reports 'HirEpsilonReadsInput' iff the+-- update reads the input; for a non-empty output it groups 'OPack's by input+-- constructor name, unions the recovered slots, and classifies misses as absent+-- from the union or present only after the head event (first-seen order,+-- deterministic).+hiddenInputReasons ::+  forall phi rs ci co s. Edge phi rs ci co s -> [HiddenInputReason]+hiddenInputReasons e = case output e of+  []+    | edgeReadsInput e -> [HirEpsilonReadsInput]+    | otherwise -> []+  outs@(headOut : _) -> concatMap (reasonsFor headOut) (groupByInCtorName outs)+  where+    reasonsFor ::+      OutTerm rs ci co ->+      (String, [String], [String]) ->+      [HiddenInputReason]+    reasonsFor headOut (icN, allSlots, visitedUnion) =+      [HirUnionMiss icN missingFromUnion | not (null missingFromUnion)]+        ++ [HirHeadUnrecoverable icN tailOnly | not (null tailOnly)]+      where+        missingFromUnion = allSlots \\ nub visitedUnion+        missingFromHead = case headOut of+          OPack headIc _ headFields+            | icName headIc == icN ->+                maybe [] mifMissing (detectMissingInCtorFields headIc headFields)+            | otherwise -> allSlots+        tailOnly = missingFromHead \\ missingFromUnion++    -- Walk the output list, accumulating per-InCtor (slot list, visited+    -- slots). First seen wins on the slot list; subsequent OPacks with the+    -- same InCtor name extend the visited list.+    groupByInCtorName ::+      [OutTerm rs ci co] -> [(String, [String], [String])]+    groupByInCtorName = foldl add []+      where+        add acc (OPack ic _ fields) =+          let icN = icName ic+              allSl = slotNamesOf ic+              visited = visitedSlotsOf ic fields+           in extend acc icN allSl visited++        extend [] icN allSl visited = [(icN, allSl, visited)]+        extend ((n, sl, v) : rest) icN allSl visited+          | n == icN = (n, sl, v ++ visited) : rest+          | otherwise = (n, sl, v) : extend rest icN allSl visited++    -- Slots of an OPack's named 'InCtor' that the supplied 'OutFields' walk+    -- recovers via a /top-level/ 'TInpCtorField'. Since EP-47 this does NOT+    -- descend into derived ('TApp1'\/'TApp2'\/'TArith') terms: a slot read+    -- only inside a derived field is a /hidden input/, so it is reported+    -- missing rather than counted as covered.+    visitedSlotsOf ::+      forall ifs fs.+      InCtor ci ifs -> OutFields rs ci ifs fs -> [String]+    visitedSlotsOf ic@InCtor {} fields = goFields fields+      where+        allSlots = slotNamesOf ic++        goFields :: forall fs'. OutFields rs ci ifs fs' -> [String]+        goFields OFNil = []+        goFields (OFCons tt rest) = goTerm tt ++ goFields rest++        goTerm :: forall r. Term rs ci ifs r -> [String]+        goTerm (TInpCtorField ic2 ix)+          | icName ic2 == icName ic =+              [allSlots !! indexPos ix]+          | otherwise = []+        goTerm _ = [] -- do not descend into derived terms+        indexPos :: forall rs' r. Index rs' r -> Int+        indexPos ZIdx = 0+        indexPos (SIdx i) = 1 + indexPos i++-- | Format a 'HiddenInputReason' into the legacy 'HiddenInputWarning' reason+-- string. The output is byte-identical to the pre-refactor 'checkHiddenInputs'+-- text so existing consumers and tests are unaffected.+formatHiddenInputReason :: Int -> HiddenInputReason -> String+formatHiddenInputReason n HirEpsilonReadsInput =+  "edge #" <> show n <> ": ε-edge with input read in update"+formatHiddenInputReason n (HirUnionMiss icN missing) =+  "edge #"+    <> show n+    <> ": OPack walk for InCtor \""+    <> icN+    <> "\" leaves field"+    <> (if length missing == 1 then " " else "s ")+    <> "{"+    <> showMissing missing+    <> "} unrecovered"+  where+    showMissing :: [String] -> String+    showMissing [] = ""+    showMissing [x] = "\"" <> x <> "\""+    showMissing (x : xs) = "\"" <> x <> "\", " <> showMissing xs+formatHiddenInputReason n (HirHeadUnrecoverable icN missing) =+  "edge #"+    <> show n+    <> ": head event does not recover InCtor \""+    <> icN+    <> "\" field"+    <> (if length missing == 1 then " " else "s ")+    <> "{"+    <> showMissing missing+    <> "}; the data appears only in later events of this edge, which replay cannot invert - move the field(s) into the FIRST emitted event"+  where+    showMissing :: [String] -> String+    showMissing [] = ""+    showMissing [x] = "\"" <> x <> "\""+    showMissing (x : xs) = "\"" <> x <> "\", " <> showMissing xs++-- | Does the 'Update' read the input symbol via 'TInpCtorField'?+updateReadsInput :: Update rs w ci -> Bool+updateReadsInput UKeep = False+updateReadsInput (USet _ t) = termReadsInput t+updateReadsInput (UCombine a b) = updateReadsInput a || updateReadsInput b++-- | Does the 'Term' read the input symbol via 'TInpCtorField'?+termReadsInput :: Term rs ci ifs r -> Bool+termReadsInput (TLit _) = False+termReadsInput (TReg _) = False+termReadsInput (TInpCtorField _ _) = True+termReadsInput (TApp1 _ t) = termReadsInput t+termReadsInput (TApp2 _ a b) = termReadsInput a || termReadsInput b+termReadsInput (TArith _ a b) = termReadsInput a || termReadsInput b++-- | Do the 'OutFields' contain a 'TInpCtorField' read anywhere?+outFieldsHaveInpCtorField :: OutFields rs ci ifs fs -> Bool+outFieldsHaveInpCtorField OFNil = False+outFieldsHaveInpCtorField (OFCons t rest) =+  termHasInpCtorField t || outFieldsHaveInpCtorField rest+  where+    termHasInpCtorField :: Term rs ci ifs r -> Bool+    termHasInpCtorField (TLit _) = False+    termHasInpCtorField (TReg _) = False+    termHasInpCtorField (TInpCtorField _ _) = True+    termHasInpCtorField (TApp1 _ t') = termHasInpCtorField t'+    termHasInpCtorField (TApp2 _ a b) = termHasInpCtorField a || termHasInpCtorField b+    termHasInpCtorField (TArith _ a b) = termHasInpCtorField a || termHasInpCtorField b++-- | The result of 'detectMissingInCtorFields': the offending 'InCtor'+-- name plus the names of slots its 'OutFields' walk does not visit.+data MissingInCtorFields = MissingInCtorFields+  { mifIcName :: String,+    mifMissing :: [String]+  }+  deriving (Eq, Show)++-- | Given the 'InCtor' an 'OPack' is tagged with and that 'OPack'\'s+-- 'OutFields', return the field names of the 'InCtor' that the+-- 'OutFields' walk does not visit. 'Nothing' means every slot of the+-- 'InCtor' is visited. The slot list comes from the 'InCtor' itself+-- (via 'KnownSlotNames'), not from any 'TInpCtorField' inside the+-- 'OutFields' — this lets us flag empty 'OutFields' against a non-+-- empty 'InCtor' as well.+detectMissingInCtorFields ::+  forall rs ci ifs fs.+  InCtor ci ifs ->+  OutFields rs ci ifs fs ->+  Maybe MissingInCtorFields+detectMissingInCtorFields ic@InCtor {} fields =+  case allSlots \\ nub visited of+    [] -> Nothing+    missing -> Just (MissingInCtorFields (icName ic) missing)+  where+    allSlots = slotNamesOf ic+    visited = goFields fields++    goFields :: forall fs'. OutFields rs ci ifs fs' -> [String]+    goFields OFNil = []+    goFields (OFCons t rest) = goTerm t ++ goFields rest++    goTerm :: forall r. Term rs ci ifs r -> [String]+    goTerm (TInpCtorField ic2 ix)+      | icName ic2 == icName ic =+          [allSlots !! indexPos ix]+      | otherwise = []+    goTerm _ = [] -- EP-47: top-level reads only; derived+    -- (TApp/TArith) terms are not descended+    -- into, so a slot read only inside one is+    -- reported missing (a hidden input).+    indexPos :: forall rs' r. Index rs' r -> Int+    indexPos ZIdx = 0+    indexPos (SIdx i) = 1 + indexPos i++-- | Read the slot-name list out of an 'InCtor' (uses the+-- 'KnownSlotNames' instance carried by the data constructor).+slotNamesOf :: forall ci ifs. InCtor ci ifs -> [String]+slotNamesOf InCtor {} = slotNames @ifs++-- * Build-time validation umbrella (EP-56) --------------------------------++-- | A structured build-time validation warning, parameterized over the+-- vertex type @s@ so it carries the real source vertex rather than a+-- pre-stringified one. It reuses the canonical 'EdgeRef' locator owned by EP-55+-- (the runtime explainer 'stepEither'), so the runtime and build-time+-- diagnostics speak one vocabulary.+--+-- Produced by 'validateTransducer'. The warning constructors cover replay+-- recoverability, safe input reads, deterministic inversion, and structural+-- reachability.+data TransducerValidationWarning s+  = -- | An edge consumes command information that its output does not+    --       emit, so the command cannot be reconstructed on replay.+    HiddenInput+      { tvwEdge :: EdgeRef s,+        -- | input constructor name, when known+        tvwInCtor :: Maybe String,+        -- | slot/field names left off the wire+        tvwMissingSlots :: [String],+        -- | human-readable summary+        tvwDetail :: String+      }+  | -- | A multi-event edge whose first event cannot recover all command+    --       fields even though later events carry the missing data. Streaming+    --       replay inverts only the first event, so the edge produces a log it+    --       cannot replay.+    HeadUnrecoverable+      { tvwEdge :: EdgeRef s,+        tvwInCtor :: Maybe String,+        tvwTailOnlySlots :: [String],+        tvwDetail :: String+      }+  | -- | Two outgoing edges use the same wire constructor for their first+    --       event. Replay requires a unique inverting edge, so the observed+    --       event may reconstruct commands for both edges and become ambiguous.+    InversionAmbiguity+      { tvwSource :: s,+        tvwEdgeA :: Int,+        tvwEdgeB :: Int,+        tvwWireCtor :: String,+        tvwDetail :: String+      }+  | -- | An edge reads a field of an input constructor without first+    --       establishing the matching top-level 'PInCtor' guard. A different+    --       command can reach the read and make 'evalTerm' throw.+    UnguardedInputRead+      { tvwEdge :: EdgeRef s,+        tvwInCtor :: Maybe String,+        tvwDetail :: String+      }+  | -- | An output-free edge changes control state or can write registers.+    --       Persisting only emitted events loses that transition, so replay of+    --       the resulting log cannot reproduce the forward state.+    StateChangingEpsilon+      { tvwEdge :: EdgeRef s,+        tvwChangesVertex :: Bool,+        tvwWritesRegisters :: Bool,+        tvwDetail :: String+      }+  | -- | Two outgoing edges of the same vertex whose guards can both hold+    --       for one command — a runtime nondeterminism / single-valuedness+    --       violation (its dynamic witness is EP-55's @AmbiguousEdges@).+    NondeterministicPair+      { tvwSource :: s,+        tvwEdgeA :: Int,+        tvwEdgeB :: Int,+        tvwInCtor :: Maybe String,+        tvwDetail :: String+      }+  | -- | An edge that can never fire: its source vertex is unreachable+    --       from 'initial', or its guard is statically unsatisfiable. Labelled+    --       "possibly" because the structural pass is conservative.+    PossiblyDeadEdge+      { tvwEdge :: EdgeRef s,+        tvwDetail :: String+      }+  | -- | An edge whose guard contains an opaque 'TApp' term. The symbolic+    --       single-valuedness and dead-edge analyses translate such a term to an+    --       unconstrained free variable ('Keiki.Symbolic.translateTermSym' emits+    --       @SBV.free "app1"@), so they cannot see through the guard and silently+    --       under-verify it. Most often this is a collection-content condition+    --       (membership, "all resolved", size) lifted through a closure because the+    --       structural predicate language has no node for it; see the user guide and+    --       @docs\/plans\/60-first-class-collection-registers-design-gated.md@ for the+    --       options. Advisory, not a soundness error: opt in via 'warnOpaqueGuards'.+    OpaqueGuard+      { tvwEdge :: EdgeRef s,+        tvwDetail :: String+      }+  deriving stock (Eq, Show)++-- | Which checks 'validateTransducer' runs. Construct options by updating+-- 'defaultValidationOptions'; new fields are added as new checks land.+data ValidationOptions = ValidationOptions+  { -- | run the hidden-input check+    failOnEpsilonReadsInput :: Bool,+    -- | run the (pure, structural) determinism check+    checkDeterminism :: Bool,+    -- | run the (structural) dead-edge check+    checkReachability :: Bool,+    -- | run the opaque-guard audit (opt-in; default off). Flags edges whose+    --     guard branches on an opaque 'TApp' term the symbolic analyses cannot+    --     see through. Off by default so 'defaultValidationOptions' keeps its+    --     meaning for existing consumers.+    warnOpaqueGuards :: Bool,+    -- | require the first emitted event to recover every command field used+    --     by replay+    checkHeadRecoverability :: Bool,+    -- | conservatively flag outgoing edge pairs with the same head wire+    --     constructor+    checkInversionAmbiguity :: Bool,+    -- | require every input-field read to be protected by an earlier matching+    --     constructor guard+    checkGuardImpliesInputRead :: Bool,+    -- | reject output-free edges that change vertex or can write registers.+    --     Never disable this for a transducer whose events are persisted:+    --     downstream durable boundaries must force-enable it rather than let+    --     callers opt out.+    checkStateChangingEpsilon :: Bool+  }+  deriving stock (Eq, Show)++-- | Replay-safety checks enabled; the advisory opaque-guard audit off.+defaultValidationOptions :: ValidationOptions+defaultValidationOptions =+  ValidationOptions+    { failOnEpsilonReadsInput = True,+      checkDeterminism = True,+      checkReachability = True,+      warnOpaqueGuards = False,+      checkHeadRecoverability = True,+      checkInversionAmbiguity = True,+      checkGuardImpliesInputRead = True,+      checkStateChangingEpsilon = True+    }++-- | The build-time validation umbrella. Runs the enabled checks over the+-- 'HsPred' (syntactic, /no solver/) carrier and concatenates their structured+-- warnings, so a project can put @validateTransducer defaultValidationOptions t+-- == []@ directly in a unit test and have it pass or fail in microseconds with+-- no external z3 process.+--+-- Subject to honest 'InCtor' and 'WireCtor' implementations, a transducer for+-- which this returns @[]@ under 'defaultValidationOptions' can replay every log+-- it produces via 'reconstitute'. 'HeadUnrecoverable',+-- 'InversionAmbiguity', 'UnguardedInputRead', and+-- 'StateChangingEpsilon' exist to align build-time acceptance with the+-- head-first semantics of 'applyEventStreaming'. Disabling those checks weakens+-- that replay guarantee.+--+-- The default path is deliberately specialised to the 'HsPred' carrier and is+-- /cheap and pure/: the determinism component flags only structurally-provable+-- overlaps in conjunction spines containing constructor tests and+-- variable-versus-literal comparisons. It uses exact interval reasoning for+-- integral variables and concrete literal witnesses for other types. Disjunction,+-- negation, arithmetic, opaque terms, variable-versus-variable comparisons, and+-- non-integral strict-bound density remain unknown and produce no pure warning.+-- The pass therefore has no false positives but can miss overlaps outside that+-- fragment. The dead-edge component is structural reachability plus a literal-+-- 'PBot' check. For the exact, solver-backed answers use+-- 'Keiki.Symbolic.checkTransitionDeterminismSym' and+-- 'Keiki.Symbolic.checkDeadEdgesSym' directly.+validateTransducer ::+  (Bounded s, Enum s, Ord s, Show s) =>+  ValidationOptions ->+  SymTransducer (HsPred rs ci) rs s ci co ->+  [TransducerValidationWarning s]+validateTransducer opts t =+  concat+    [ if failOnEpsilonReadsInput opts then hiddenInputWarnings t else [],+      if checkHeadRecoverability opts then headRecoverabilityWarnings t else [],+      if checkInversionAmbiguity opts then inversionAmbiguityWarnings t else [],+      if checkGuardImpliesInputRead opts then guardImpliesInputReadWarnings t else [],+      if checkStateChangingEpsilon opts then stateChangingEpsilonWarnings t else [],+      if checkDeterminism opts then determinismWarnings t else [],+      if checkReachability opts+        then+          [ PossiblyDeadEdge (dewEdge w) (dewReason w)+          | w <- checkDeadEdges defaultDeadEdgeOptions t+          ]+        else [],+      if warnOpaqueGuards opts then opaqueGuardWarnings t else []+    ]++-- | Structured form of the hidden-input check, additive over+-- 'checkHiddenInputs'. Reuses the same per-edge analysis ('hiddenInputReasons')+-- and lifts each result into a 'TransducerValidationWarning' carrying the typed+-- source vertex (via 'EdgeRef'), the input-constructor name, and the missing+-- slot names — data a downstream project can pattern-match on rather than parse+-- out of a string.+hiddenInputWarnings ::+  (Bounded s, Enum s) =>+  SymTransducer phi rs s ci co ->+  [TransducerValidationWarning s]+hiddenInputWarnings t =+  [ HiddenInput+      { tvwEdge = EdgeRef {edgeSource = s, edgeIndex = n},+        tvwInCtor = inCtorOf r,+        tvwMissingSlots = missingSlotsOf r,+        tvwDetail = formatHiddenInputReason n r+      }+  | s <- [minBound .. maxBound],+    (n, e) <- zip [(0 :: Int) ..] (edgesOut t s),+    r <- hiddenInputReasons e,+    isHiddenReason r+  ]+  where+    inCtorOf (HirUnionMiss icN _) = Just icN+    inCtorOf HirEpsilonReadsInput = Nothing+    inCtorOf (HirHeadUnrecoverable _ _) = Nothing+    missingSlotsOf (HirUnionMiss _ ms) = ms+    missingSlotsOf HirEpsilonReadsInput = []+    missingSlotsOf (HirHeadUnrecoverable _ _) = []+    isHiddenReason HirEpsilonReadsInput = True+    isHiddenReason (HirUnionMiss _ _) = True+    isHiddenReason (HirHeadUnrecoverable _ _) = False++-- | Warn when a multi-event edge's head event cannot alone recover command+-- fields that appear later in the same output chain. 'applyEventStreaming'+-- inverts only the head output; tail events are equality-checked after the edge+-- has already been selected.+headRecoverabilityWarnings ::+  (Bounded s, Enum s) =>+  SymTransducer phi rs s ci co ->+  [TransducerValidationWarning s]+headRecoverabilityWarnings t =+  [ HeadUnrecoverable+      { tvwEdge = EdgeRef {edgeSource = s, edgeIndex = n},+        tvwInCtor = Just icN,+        tvwTailOnlySlots = missing,+        tvwDetail = formatHiddenInputReason n reason+      }+  | s <- [minBound .. maxBound],+    (n, e) <- zip [(0 :: Int) ..] (edgesOut t s),+    reason@(HirHeadUnrecoverable icN missing) <- hiddenInputReasons e+  ]++-- ** Opaque-guard diagnostics++-- | Does the term contain an opaque 'TApp1'\/'TApp2' anywhere? Mirrors the+-- structural recursion of 'termReadsInput'; 'TArith' is transparent, so it+-- recurses into its operands rather than counting as opaque.+termHasOpaqueApp :: Term rs ci ifs r -> Bool+termHasOpaqueApp (TLit _) = False+termHasOpaqueApp (TReg _) = False+termHasOpaqueApp (TInpCtorField _ _) = False+termHasOpaqueApp (TApp1 _ _) = True+termHasOpaqueApp (TApp2 _ _ _) = True+termHasOpaqueApp (TArith _ a b) = termHasOpaqueApp a || termHasOpaqueApp b++-- | Does the guard predicate branch on an opaque term anywhere? The symbolic+-- analyses cannot see through such a guard (it becomes a free SBV variable),+-- so they silently under-verify the edge.+predHasOpaqueTerm :: HsPred rs ci -> Bool+predHasOpaqueTerm PTop = False+predHasOpaqueTerm PBot = False+predHasOpaqueTerm (PAnd p q) = predHasOpaqueTerm p || predHasOpaqueTerm q+predHasOpaqueTerm (POr p q) = predHasOpaqueTerm p || predHasOpaqueTerm q+predHasOpaqueTerm (PNot p) = predHasOpaqueTerm p+predHasOpaqueTerm (PEq a b) = termHasOpaqueApp a || termHasOpaqueApp b+predHasOpaqueTerm (PInCtor _) = False+predHasOpaqueTerm PLeftArm = False+predHasOpaqueTerm PRightArm = False+predHasOpaqueTerm (PCmp _ a b) = termHasOpaqueApp a || termHasOpaqueApp b++-- | The opt-in opaque-guard audit (run by 'validateTransducer' only when+-- 'warnOpaqueGuards' is set). For every edge whose guard contains an opaque+-- 'TApp' term, emit an 'OpaqueGuard' warning locating the edge by its typed+-- 'EdgeRef'. Specialised to the 'HsPred' carrier because it walks the predicate+-- AST, exactly as 'validateTransducer' is.+opaqueGuardWarnings ::+  (Bounded s, Enum s) =>+  SymTransducer (HsPred rs ci) rs s ci co ->+  [TransducerValidationWarning s]+opaqueGuardWarnings t =+  [ OpaqueGuard+      { tvwEdge = EdgeRef {edgeSource = s, edgeIndex = n},+        tvwDetail =+          "guard contains an opaque TApp term the symbolic analyses cannot "+            ++ "see through; its single-valuedness was not verified"+      }+  | s <- [minBound .. maxBound],+    (n, e) <- zip [(0 :: Int) ..] (edgesOut t s),+    predHasOpaqueTerm (guard e)+  ]++-- ** Guarded input-read diagnostics++termInCtorNames :: Term rs ci ifs r -> [String]+termInCtorNames (TLit _) = []+termInCtorNames (TReg _) = []+termInCtorNames (TInpCtorField ic _) = [icName ic]+termInCtorNames (TApp1 _ t) = termInCtorNames t+termInCtorNames (TApp2 _ a b) = termInCtorNames a ++ termInCtorNames b+termInCtorNames (TArith _ a b) = termInCtorNames a ++ termInCtorNames b++predInCtorReadNames :: HsPred rs ci -> [String]+predInCtorReadNames PTop = []+predInCtorReadNames PBot = []+predInCtorReadNames (PAnd p q) = predInCtorReadNames p ++ predInCtorReadNames q+predInCtorReadNames (POr p q) = predInCtorReadNames p ++ predInCtorReadNames q+predInCtorReadNames (PNot p) = predInCtorReadNames p+predInCtorReadNames (PEq a b) = termInCtorNames a ++ termInCtorNames b+predInCtorReadNames (PInCtor _) = []+predInCtorReadNames PLeftArm = []+predInCtorReadNames PRightArm = []+predInCtorReadNames (PCmp _ a b) = termInCtorNames a ++ termInCtorNames b++updateInCtorNames :: Update rs w ci -> [String]+updateInCtorNames UKeep = []+updateInCtorNames (USet _ term) = termInCtorNames term+updateInCtorNames (UCombine a b) = updateInCtorNames a ++ updateInCtorNames b++outFieldsInCtorNames :: OutFields rs ci ifs fs -> [String]+outFieldsInCtorNames OFNil = []+outFieldsInCtorNames (OFCons term rest) =+  termInCtorNames term ++ outFieldsInCtorNames rest++outTermInCtorNames :: OutTerm rs ci co -> [String]+outTermInCtorNames (OPack _ _ fields) = outFieldsInCtorNames fields++-- | Check that every 'TInpCtorField' read is protected by a matching+-- top-level 'PInCtor' conjunct. Guard reads are order-sensitive: the guard is+-- walked along its top-level 'PAnd' spine from left to right, matching Haskell's+-- lazy @(&&)@ evaluation. A constructor established to the right of a read is+-- too late. Reads in updates and outputs run after the complete guard succeeds,+-- so any constructor established on the spine protects them.+--+-- 'POr', 'PNot', comparisons, and nested boolean forms establish nothing; a+-- constructor atom inside them does not imply that the whole guard matched that+-- constructor. Builder 'onCmd' edges are safe by construction because their+-- guard starts with the appropriate 'PInCtor'. An unsatisfiable conjunction of+-- different constructors may pass this crash-safety check and is left to the+-- dead-edge analyses.+guardImpliesInputReadWarnings ::+  forall rs s ci co.+  (Bounded s, Enum s, Show s) =>+  SymTransducer (HsPred rs ci) rs s ci co ->+  [TransducerValidationWarning s]+guardImpliesInputReadWarnings t =+  [ UnguardedInputRead+      { tvwEdge = EdgeRef {edgeSource = s, edgeIndex = n},+        tvwInCtor = Just icN,+        tvwDetail =+          location+            <> " reads InCtor \""+            <> icN+            <> "\" but edge #"+            <> show n+            <> " out of "+            <> show s+            <> " does not establish PInCtor \""+            <> icN+            <> "\" before it; a non-\""+            <> icN+            <> "\" command reaching this edge crashes instead of being rejected"+      }+  | s <- [minBound .. maxBound],+    (n, edge) <- zip [(0 :: Int) ..] (edgesOut t s),+    (icN, location) <- edgeViolations edge+  ]+  where+    edgeViolations :: Edge (HsPred rs ci) rs ci co s -> [(String, String)]+    edgeViolations Edge {guard = edgeGuard, update = edgeUpdate, output = edgeOutput} =+      nub (guardViolations ++ updateViolations ++ outputViolations)+      where+        (guardViolations, established) = checkGuard [] edgeGuard+        updateViolations =+          [ (icN, "update")+          | icN <- nub (updateInCtorNames edgeUpdate),+            icN `notElem` established+          ]+        outputViolations =+          [ (icN, "output")+          | icN <- nub (concatMap outTermInCtorNames edgeOutput),+            icN `notElem` established+          ]++    checkGuard :: [String] -> HsPred rs ci -> ([(String, String)], [String])+    checkGuard established (PAnd p q) =+      let (leftViolations, afterLeft) = checkGuard established p+          (rightViolations, afterRight) = checkGuard afterLeft q+       in (leftViolations ++ rightViolations, afterRight)+    checkGuard established (PInCtor ic) =+      ([], nub (established ++ [icName ic]))+    checkGuard established predicate =+      ( [ (icN, "guard")+        | icN <- nub (predInCtorReadNames predicate),+          icN `notElem` established+        ],+        established+      )++-- ** State-changing epsilon diagnostics++updateWritesRegisters :: Update rs w ci -> Bool+updateWritesRegisters UKeep = False+updateWritesRegisters USet {} = True+updateWritesRegisters (UCombine a b) =+  updateWritesRegisters a || updateWritesRegisters b++-- | Flag output-free edges that change control state or are structurally+-- capable of writing registers. Such an edge can be meaningful when a+-- transducer is used only as an in-memory state machine, but it is not durable:+-- persisting the empty output loses the transition and replay cannot reproduce+-- its result.+--+-- Control-state detection is exact. Register detection is conservative:+-- 'USet', or any 'UCombine' containing one, warns even when a particular write+-- happens to store the value already present. A self-loop with 'UKeep' remains+-- clean.+stateChangingEpsilonWarnings ::+  forall rs s ci co.+  (Bounded s, Enum s, Eq s, Show s) =>+  SymTransducer (HsPred rs ci) rs s ci co ->+  [TransducerValidationWarning s]+stateChangingEpsilonWarnings t =+  [ StateChangingEpsilon+      { tvwEdge = EdgeRef {edgeSource = source, edgeIndex = edgeNumber},+        tvwChangesVertex = changesVertex,+        tvwWritesRegisters = writesRegisters,+        tvwDetail =+          "output-free edge #"+            <> show edgeNumber+            <> " out of "+            <> show source+            <> changeSummary changesVertex writesRegisters+            <> "; persisted replay cannot reproduce that transition without an emitted event"+      }+  | source <- [minBound .. maxBound],+    (edgeNumber, Edge {update = edgeUpdate, output = edgeOutput, target = edgeTarget}) <-+      zip [(0 :: Int) ..] (edgesOut t source),+    null edgeOutput,+    let changesVertex = edgeTarget /= source,+    let writesRegisters = updateWritesRegisters edgeUpdate,+    changesVertex || writesRegisters+  ]+  where+    changeSummary True True = " changes vertex and can write registers"+    changeSummary True False = " changes vertex"+    changeSummary False True = " can write registers"+    changeSummary False False = ""++-- ** Replay inversion diagnostics++-- | Conservatively flag pairs of outgoing edges whose first emitted events+-- use the same 'WireCtor' name. Replay selects an edge by inverting one observed+-- head event, so both edges may reconstruct their own commands and satisfy their+-- own guards even when forward command dispatch is deterministic.+--+-- This structural check intentionally over-approximates ambiguity. It cannot+-- prove semantic guard disjointness over recovered values or registers; it+-- cannot compare differing 'TLit' values because 'TLit' carries no 'Eq' or+-- 'Typeable' evidence; and it does not predict derived-field verification in+-- 'solveOutput'. It ignores tail events because replay equality-checks rather+-- than inverts them. Different head constructor names are safe under the+-- documented honesty law of 'wcMatch'. Literal-'PBot' guards are exempt because+-- such an edge cannot participate in a successful inversion.+inversionAmbiguityWarnings ::+  forall rs s ci co.+  (Bounded s, Enum s, Show s) =>+  SymTransducer (HsPred rs ci) rs s ci co ->+  [TransducerValidationWarning s]+inversionAmbiguityWarnings t =+  [ InversionAmbiguity+      { tvwSource = s,+        tvwEdgeA = i,+        tvwEdgeB = j,+        tvwWireCtor = wireName,+        tvwDetail =+          "edges #"+            <> show i+            <> " and #"+            <> show j+            <> " out of "+            <> show s+            <> " both emit \""+            <> wireName+            <> "\" as their first event; replay may not be able to attribute an observed \""+            <> wireName+            <> "\" to a unique edge"+      }+  | s <- [minBound .. maxBound],+    let indexedEdges = zip [(0 :: Int) ..] (edgesOut t s),+    (i, e1) <- indexedEdges,+    (j, e2) <- indexedEdges,+    i < j,+    not (isBot (guard e1) || isBot (guard e2)),+    Just wireName <- [headWireName e1],+    Just otherWireName <- [headWireName e2],+    wireName == otherWireName+  ]+  where+    headWireName :: Edge (HsPred rs ci) rs ci co s -> Maybe String+    headWireName Edge {output = OPack _ wire _ : _} = Just (wcName wire)+    headWireName _ = Nothing++-- ** Determinism diagnostics++-- | A determinism warning: two outgoing edges of the same vertex whose guards+-- can both hold. Carries both edge indices and the (typed) source vertex.+data DeterminismWarning s = DeterminismWarning+  { dwSource :: s,+    -- | first overlapping edge index+    dwEdgeA :: Int,+    -- | second overlapping edge index+    dwEdgeB :: Int,+    dwDetail :: String+  }+  deriving stock (Eq, Show)++-- | Per-vertex, per-pair determinism diagnostic. Reuses the exact pairing+-- structure of 'Keiki.Symbolic.isSingleValuedSym': for every vertex, for every+-- pair @(i,e1),(j,e2)@ with @i<j@, the pair is ambiguous when+-- @guard e1 \`conj\` guard e2@ is /not/ 'isBot'. So+-- @checkTransitionDeterminism t == []@ iff @isSingleValuedSym t@ under the same+-- carrier.+--+-- Soundness direction: with the pure 'HsPred' carrier, 'isBot' only recognises+-- the literal 'PBot', so @not (isBot (a \`conj\` b))@ holds for /every/ non-'PBot'+-- pair — i.e. this polymorphic check over-approximates overlap on the 'HsPred'+-- carrier (it would flag almost every multi-edge vertex). It is intended to be+-- run over the /symbolic/ 'SymPred' carrier (via+-- 'Keiki.Symbolic.checkTransitionDeterminismSym'), whose 'isBot' is exact. For+-- the pure path 'validateTransducer' uses the under-approximating+-- 'checkTransitionDeterminismPure' instead, which flags only true positives.+checkTransitionDeterminism ::+  forall phi rs s ci co.+  (BoolAlg phi (RegFile rs, ci), Bounded s, Enum s, Show s) =>+  SymTransducer phi rs s ci co ->+  [DeterminismWarning s]+checkTransitionDeterminism t =+  [ DeterminismWarning+      { dwSource = s,+        dwEdgeA = i,+        dwEdgeB = j,+        dwDetail = overlapDetail i j s+      }+  | s <- [minBound .. maxBound],+    let ies = zip [(0 :: Int) ..] (edgesOut t s),+    (i, e1) <- ies,+    (j, e2) <- ies,+    i < j,+    not (isBot (guard e1 `conj` guard e2))+  ]++-- | Over-approximation-free determinism check for the pure 'HsPred' carrier.+-- It proves overlap through conjunction spines containing 'PTop', compatible+-- 'PInCtor' atoms, and variable-versus-literal equality or ordering atoms.+-- Integral variables ('Int', 'Integer', fixed-width words and signed integers)+-- use exact interval intersection; other types require one of the mentioned+-- literals to be a concrete witness. Disjunction, negation, structural+-- arithmetic, opaque terms, variable-versus-variable comparisons, and strict+-- non-integral bounds are outside the fragment and produce no warning. Used by+-- 'validateTransducer' so every warning is a true positive. Absence does not+-- prove determinism; run 'Keiki.Symbolic.checkTransitionDeterminismSym' for the+-- exact solver-backed answer.+checkTransitionDeterminismPure ::+  forall rs s ci co.+  (Bounded s, Enum s, Show s) =>+  SymTransducer (HsPred rs ci) rs s ci co ->+  [DeterminismWarning s]+checkTransitionDeterminismPure t =+  [ DeterminismWarning+      { dwSource = s,+        dwEdgeA = i,+        dwEdgeB = j,+        dwDetail = overlapDetail i j s+      }+  | s <- [minBound .. maxBound],+    let ies = zip [(0 :: Int) ..] (edgesOut t s),+    (i, e1) <- ies,+    (j, e2) <- ies,+    i < j,+    provablyOverlap (guard e1) (guard e2)+  ]++overlapDetail :: (Show s) => Int -> Int -> s -> String+overlapDetail i j s =+  "edges #"+    <> show i+    <> " and #"+    <> show j+    <> " out of "+    <> show s+    <> " have overlapping guards"++-- | A named variable in the pure overlap fragment. Register and input-field+-- namespaces are distinct. Input fields retain their constructor name so a+-- witness is accepted only when a matching 'PInCtor' atom is present.+data PureVariable = PureVariable+  { pureVariableName :: String,+    pureVariableInputCtor :: Maybe String+  }+  deriving stock (Eq, Show)++data PureRelation = PureEq | PureLt | PureLe | PureGt | PureGe+  deriving stock (Eq, Show)++-- | One normalized @variable relation literal@ atom. The predicate closure+-- captures the source constructor's real 'Eq' or 'Ord' dictionary for concrete+-- literal-witness probing.+data PureComparison where+  PureComparison ::+    (Typeable r) =>+    PureVariable ->+    PureRelation ->+    r ->+    (r -> Bool) ->+    PureComparison++data PureGuard = PureGuard+  { pureGuardConstructors :: [String],+    pureGuardComparisons :: [PureComparison]+  }++data PureFragment+  = PureUnknown+  | PureUnsatisfiable+  | PureKnown PureGuard++emptyPureGuard :: PureGuard+emptyPureGuard = PureGuard [] []++-- | Parse the exact, deliberately small fragment accepted by+-- 'provablyOverlap'.+pureFragment :: HsPred rs ci -> PureFragment+pureFragment PTop = PureKnown emptyPureGuard+pureFragment PBot = PureUnsatisfiable+pureFragment (PAnd a b) = mergePureFragments (pureFragment a) (pureFragment b)+pureFragment (PInCtor ic) =+  PureKnown emptyPureGuard {pureGuardConstructors = [icName ic]}+pureFragment (PEq a b) = pureEquality a b+pureFragment (PCmp relation a b) = pureOrdering relation a b+pureFragment PLeftArm = PureUnknown+pureFragment PRightArm = PureUnknown+pureFragment (POr _ _) = PureUnknown+pureFragment (PNot _) = PureUnknown++mergePureFragments :: PureFragment -> PureFragment -> PureFragment+mergePureFragments PureUnsatisfiable _ = PureUnsatisfiable+mergePureFragments _ PureUnsatisfiable = PureUnsatisfiable+mergePureFragments PureUnknown _ = PureUnknown+mergePureFragments _ PureUnknown = PureUnknown+mergePureFragments (PureKnown a) (PureKnown b) =+  PureKnown+    PureGuard+      { pureGuardConstructors =+          pureGuardConstructors a <> pureGuardConstructors b,+        pureGuardComparisons =+          pureGuardComparisons a <> pureGuardComparisons b+      }++pureEquality ::+  forall rs ci ifs1 ifs2 r.+  (Eq r, Typeable r) =>+  Term rs ci ifs1 r ->+  Term rs ci ifs2 r ->+  PureFragment+pureEquality (TLit a) (TLit b)+  | a == b = PureKnown emptyPureGuard+  | otherwise = PureUnsatisfiable+pureEquality variable (TLit literalValue)+  | Just name <- pureVariable variable =+      knownComparison name PureEq literalValue (== literalValue)+pureEquality (TLit literalValue) variable+  | Just name <- pureVariable variable =+      knownComparison name PureEq literalValue (== literalValue)+pureEquality _ _ = PureUnknown++pureOrdering ::+  forall rs ci ifs1 ifs2 r.+  (Ord r, Typeable r) =>+  Cmp ->+  Term rs ci ifs1 r ->+  Term rs ci ifs2 r ->+  PureFragment+pureOrdering relation (TLit a) (TLit b)+  | applyPureCmp relation a b = PureKnown emptyPureGuard+  | otherwise = PureUnsatisfiable+pureOrdering relation variable (TLit literalValue)+  | Just name <- pureVariable variable =+      let normalized = pureRelation relation+       in knownComparison+            name+            normalized+            literalValue+            (\value -> applyPureRelation normalized value literalValue)+pureOrdering relation (TLit literalValue) variable+  | Just name <- pureVariable variable =+      let normalized = flipPureRelation (pureRelation relation)+       in knownComparison+            name+            normalized+            literalValue+            (\value -> applyPureRelation normalized value literalValue)+pureOrdering _ _ _ = PureUnknown++knownComparison ::+  (Typeable r) =>+  PureVariable ->+  PureRelation ->+  r ->+  (r -> Bool) ->+  PureFragment+knownComparison variable relation literalValue accepts =+  PureKnown+    emptyPureGuard+      { pureGuardComparisons =+          [PureComparison variable relation literalValue accepts]+      }++pureVariable :: Term rs ci ifs r -> Maybe PureVariable+pureVariable (TReg index) =+  Just (PureVariable ("reg/" <> pureIndexName index) Nothing)+pureVariable (TInpCtorField ic index) =+  Just+    ( PureVariable+        ("inp/" <> icName ic <> "/" <> pureIndexName index)+        (Just (icName ic))+    )+pureVariable _ = Nothing++pureIndexName :: forall rs r. Index rs r -> String+pureIndexName (ZIdx @name) = symbolVal (Proxy @name)+pureIndexName (SIdx index) = pureIndexName index++pureRelation :: Cmp -> PureRelation+pureRelation CmpLt = PureLt+pureRelation CmpLe = PureLe+pureRelation CmpGt = PureGt+pureRelation CmpGe = PureGe++flipPureRelation :: PureRelation -> PureRelation+flipPureRelation PureEq = PureEq+flipPureRelation PureLt = PureGt+flipPureRelation PureLe = PureGe+flipPureRelation PureGt = PureLt+flipPureRelation PureGe = PureLe++applyPureCmp :: (Ord r) => Cmp -> r -> r -> Bool+applyPureCmp CmpLt = (<)+applyPureCmp CmpLe = (<=)+applyPureCmp CmpGt = (>)+applyPureCmp CmpGe = (>=)++applyPureRelation :: (Ord r) => PureRelation -> r -> r -> Bool+applyPureRelation PureEq = (==)+applyPureRelation PureLt = (<)+applyPureRelation PureLe = (<=)+applyPureRelation PureGt = (>)+applyPureRelation PureGe = (>=)++-- | Structurally prove that a concrete witness exists for both guards. The+-- accepted fragment is described on 'checkTransitionDeterminismPure'. Returning+-- 'False' means either disjoint or unknown. Like the historical @PTop@ and+-- @PInCtor@ cases, this proof assumes the register and command schemas are+-- inhabited.+provablyOverlap :: HsPred rs ci -> HsPred rs ci -> Bool+provablyOverlap PLeftArm PLeftArm = True+provablyOverlap PRightArm PRightArm = True+provablyOverlap a b = case (pureFragment a, pureFragment b) of+  (PureKnown leftGuard, PureKnown rightGuard) ->+    pureGuardsOverlap leftGuard rightGuard+  _ -> False++pureGuardsOverlap :: PureGuard -> PureGuard -> Bool+pureGuardsOverlap leftGuard rightGuard = case commonPureConstructor of+  Nothing -> False+  Just constructorName ->+    all (comparisonMatchesConstructor constructorName) comparisons+      && all pureComparisonGroupSatisfiable (groupPureComparisons comparisons)+  where+    comparisons =+      pureGuardComparisons leftGuard <> pureGuardComparisons rightGuard+    commonPureConstructor = do+      leftConstructor <- singlePureConstructor (pureGuardConstructors leftGuard)+      rightConstructor <- singlePureConstructor (pureGuardConstructors rightGuard)+      compatiblePureConstructors leftConstructor rightConstructor++singlePureConstructor :: [String] -> Maybe (Maybe String)+singlePureConstructor names = case nub names of+  [] -> Just Nothing+  [name] -> Just (Just name)+  _ -> Nothing++compatiblePureConstructors ::+  Maybe String -> Maybe String -> Maybe (Maybe String)+compatiblePureConstructors Nothing Nothing = Just Nothing+compatiblePureConstructors (Just name) Nothing = Just (Just name)+compatiblePureConstructors Nothing (Just name) = Just (Just name)+compatiblePureConstructors (Just leftName) (Just rightName)+  | leftName == rightName = Just (Just leftName)+  | otherwise = Nothing++comparisonMatchesConstructor :: Maybe String -> PureComparison -> Bool+comparisonMatchesConstructor constructorName (PureComparison variable _ _ _) =+  case pureVariableInputCtor variable of+    Nothing -> True+    Just inputConstructor -> constructorName == Just inputConstructor++groupPureComparisons :: [PureComparison] -> [[PureComparison]]+groupPureComparisons [] = []+groupPureComparisons (comparison : rest) =+  (comparison : sameVariable) : groupPureComparisons otherVariables+  where+    variable = pureComparisonVariable comparison+    (sameVariable, otherVariables) =+      partition ((== variable) . pureComparisonVariable) rest++pureComparisonVariable :: PureComparison -> PureVariable+pureComparisonVariable (PureComparison variable _ _ _) = variable++data TypedPureComparison r = TypedPureComparison+  { typedPureRelation :: PureRelation,+    typedPureLiteral :: r,+    typedPureAccepts :: r -> Bool+  }++alignPureComparison ::+  forall r. (Typeable r) => PureComparison -> Maybe (TypedPureComparison r)+alignPureComparison (PureComparison @other _ relation literalValue accepts) =+  case eqTypeRep (typeRep @r) (typeRep @other) of+    Just HRefl -> Just (TypedPureComparison relation literalValue accepts)+    Nothing -> Nothing++pureComparisonGroupSatisfiable :: [PureComparison] -> Bool+pureComparisonGroupSatisfiable [] = True+pureComparisonGroupSatisfiable+  (PureComparison @r _ relation literalValue accepts : rest) =+    case traverse (alignPureComparison @r) rest of+      Nothing -> False+      Just alignedRest ->+        let comparisons =+              TypedPureComparison relation literalValue accepts : alignedRest+         in case discoverIntegralDomain @r of+              Just domain -> integralComparisonsSatisfiable domain comparisons+              Nothing -> literalWitnessSatisfies comparisons++data IntegralDomain r = IntegralDomain+  { integralValue :: r -> Integer,+    integralMinimum :: Maybe Integer,+    integralMaximum :: Maybe Integer+  }++discoverIntegralDomain :: forall r. (Typeable r) => Maybe (IntegralDomain r)+discoverIntegralDomain+  | Just HRefl <- eqTypeRep (typeRep @r) (typeRep @Integer) =+      Just (IntegralDomain id Nothing Nothing)+  | Just HRefl <- eqTypeRep (typeRep @r) (typeRep @Int) =+      Just (boundedIntegralDomain @Int)+  | Just HRefl <- eqTypeRep (typeRep @r) (typeRep @Word8) =+      Just (boundedIntegralDomain @Word8)+  | Just HRefl <- eqTypeRep (typeRep @r) (typeRep @Word16) =+      Just (boundedIntegralDomain @Word16)+  | Just HRefl <- eqTypeRep (typeRep @r) (typeRep @Word32) =+      Just (boundedIntegralDomain @Word32)+  | Just HRefl <- eqTypeRep (typeRep @r) (typeRep @Word64) =+      Just (boundedIntegralDomain @Word64)+  | Just HRefl <- eqTypeRep (typeRep @r) (typeRep @Int32) =+      Just (boundedIntegralDomain @Int32)+  | Just HRefl <- eqTypeRep (typeRep @r) (typeRep @Int64) =+      Just (boundedIntegralDomain @Int64)+  | otherwise = Nothing++boundedIntegralDomain ::+  forall r. (Integral r, Bounded r) => IntegralDomain r+boundedIntegralDomain =+  IntegralDomain+    { integralValue = toInteger,+      integralMinimum = Just (toInteger (minBound @r)),+      integralMaximum = Just (toInteger (maxBound @r))+    }++integralComparisonsSatisfiable ::+  IntegralDomain r -> [TypedPureComparison r] -> Bool+integralComparisonsSatisfiable domain comparisons =+  intervalIsNonempty finalMinimum finalMaximum+  where+    (finalMinimum, finalMaximum) =+      foldl' refine (integralMinimum domain, integralMaximum domain) comparisons++    refine (minimumValue, maximumValue) comparison =+      let literalValue = integralValue domain (typedPureLiteral comparison)+       in case typedPureRelation comparison of+            PureEq ->+              ( maximumMaybe minimumValue (Just literalValue),+                minimumMaybe maximumValue (Just literalValue)+              )+            PureLt ->+              (minimumValue, minimumMaybe maximumValue (Just (literalValue - 1)))+            PureLe ->+              (minimumValue, minimumMaybe maximumValue (Just literalValue))+            PureGt ->+              (maximumMaybe minimumValue (Just (literalValue + 1)), maximumValue)+            PureGe ->+              (maximumMaybe minimumValue (Just literalValue), maximumValue)++maximumMaybe :: Maybe Integer -> Maybe Integer -> Maybe Integer+maximumMaybe Nothing b = b+maximumMaybe a Nothing = a+maximumMaybe (Just a) (Just b) = Just (max a b)++minimumMaybe :: Maybe Integer -> Maybe Integer -> Maybe Integer+minimumMaybe Nothing b = b+minimumMaybe a Nothing = a+minimumMaybe (Just a) (Just b) = Just (min a b)++intervalIsNonempty :: Maybe Integer -> Maybe Integer -> Bool+intervalIsNonempty (Just minimumValue) (Just maximumValue) =+  minimumValue <= maximumValue+intervalIsNonempty _ _ = True++literalWitnessSatisfies :: [TypedPureComparison r] -> Bool+literalWitnessSatisfies comparisons =+  any satisfiesEveryComparison (map typedPureLiteral comparisons)+  where+    satisfiesEveryComparison candidate =+      all (\comparison -> typedPureAccepts comparison candidate) comparisons++overlapConstructor :: HsPred rs ci -> HsPred rs ci -> Maybe String+overlapConstructor leftGuard rightGuard = do+  PureKnown left <- Just (pureFragment leftGuard)+  PureKnown right <- Just (pureFragment rightGuard)+  leftConstructor <- singlePureConstructor (pureGuardConstructors left)+  rightConstructor <- singlePureConstructor (pureGuardConstructors right)+  compatible <- compatiblePureConstructors leftConstructor rightConstructor+  compatible++-- | Internal: the determinism component of 'validateTransducer'. Like+-- 'checkTransitionDeterminismPure' but emits the richer 'NondeterministicPair'+-- directly, populating 'tvwInCtor' with the common command constructor found+-- anywhere in either conjunction spine (and 'Nothing' when neither names one).+determinismWarnings ::+  (Bounded s, Enum s, Show s) =>+  SymTransducer (HsPred rs ci) rs s ci co ->+  [TransducerValidationWarning s]+determinismWarnings t =+  [ NondeterministicPair+      { tvwSource = s,+        tvwEdgeA = i,+        tvwEdgeB = j,+        tvwInCtor = overlapConstructor (guard e1) (guard e2),+        tvwDetail = overlapDetail i j s+      }+  | s <- [minBound .. maxBound],+    let ies = zip [(0 :: Int) ..] (edgesOut t s),+    (i, e1) <- ies,+    (j, e2) <- ies,+    i < j,+    provablyOverlap (guard e1) (guard e2)+  ]  -- ** Dead-edge diagnostics 
− src/Keiki/Decider.hs
@@ -1,119 +0,0 @@--- | A Chassaing-shape Decider façade over a 'SymTransducer'.------ Users coming from the naive functional event-sourcing world--- (Jérémie Chassaing's /Functional Event Sourcing Decider/) work--- with a small record:------ @--- data Decider c e s = Decider---   { decide       :: c -> s -> [e]---   , evolve       :: s -> e -> s---   , initialState :: s---   , isTerminal   :: s -> Bool---   }--- @------ 'toDecider' projects the keiki 'SymTransducer' onto this shape.--- 'decide' is built on 'omega' (the forward step that emits one or--- more events per command after the EP-19 widening); 'evolve' is--- built on 'applyEvent' (the letter-only inverse step). With the--- widened @'Keiki.Core.Edge.output' :: ['Keiki.Core.OutTerm' rs ci--- co]@ a single command can yield two or more events end-to-end--- without any state-refinement scaffolding — 'decide' returns the--- full list directly. EP-19 retired the previous EP-20 façades--- (@toMultiDecider@ + @DriverConfig@); the multi-event behaviour is--- now first-class in the AST.------ == Streaming replay through multi-event edges------ The 'evolve' field is letter-only (handles edges with output of--- length 0 or 1) and remains the canonical letter-replay verb.--- Event-by-event streaming across a length-2+ edge passes through--- the intermediate "I just observed event 1, expecting event 2 next"--- state; the 'evolveStreaming' field exposes this via--- 'Keiki.Core.InFlight' and 'Keiki.Core.applyEventStreaming'. The--- two evolve fields agree on length-0/1 commands; they diverge--- only on length-2+ where the streaming path stays /InFlight/--- between events.------ == One semantic gap remains------ /ε-edges/ — edges whose @output@ is @[]@. The transducer--- transitions state without emitting an event. 'decide' returns--- @[]@ for such commands, and the result is identical to "no event--- happened" from the Decider record's perspective. Use--- 'Keiki.Core.delta' / 'Keiki.Core.step' directly when ε-driven--- transitions matter.-module Keiki.Decider-  ( Decider (..),-    toDecider,-  )-where--import Keiki.Core-  ( BoolAlg,-    InFlight (..),-    RegFile,-    SymTransducer (..),-    applyEvent,-    applyEventStreaming,-    omega,-  )---- | The Chassaing-shape Decider record. Field selectors are named--- to match published Decider examples; conflicts with other modules--- are avoided by importing this module qualified.------ The @s_streaming@ parameter carries the InFlight-aware streaming--- state ('Keiki.Core.InFlight' s co paired with a register file);--- for letter-only callers it is unused.-data Decider c e s s_streaming = Decider-  { decide :: c -> s -> [e],-    evolve :: s -> e -> s,-    evolveStreaming :: s_streaming -> e -> Maybe s_streaming,-    initialState :: s,-    isTerminal :: s -> Bool-  }---- | Project a keiki 'SymTransducer' to a 'Decider' record. The--- letter-replay state carrier is @(s, RegFile rs)@ and the--- streaming-replay state carrier is @('Keiki.Core.InFlight' s co,--- RegFile rs)@ — keiki edge guards depend on the register file as--- well as the control vertex, and streaming replay through a--- length-2+ edge intrinsically observes a mid-chain wrapper.------ == Field-by-field correspondence------ @--- decide d cmd (s, regs)        -- = omega t s regs cmd            (EP-19 widened)--- evolve d (s, regs) ev         -- = letter-only applyEvent;---                               --   on length-2+ edges, falls back---                               --   to the input state defensively--- evolveStreaming d ws ev       -- = applyEventStreaming, returning---                               --   the wrapped state mid-chain--- initialState d                -- = (initial t, initialRegs t)--- isTerminal   d (s, _regs)     -- = isFinal t s--- @------ == Defensive 'evolve'------ 'applyEvent' returns 'Nothing' when an event cannot be replayed--- letter-by-letter from @(s, regs)@. To keep the Chassaing signature--- non-'Maybe', 'evolve' returns the input state on failure. Callers--- that want strict replay use 'evolveStreaming' (whose 'Maybe' is--- explicit) or 'Keiki.Core.applyEvents' (which returns 'Nothing' on--- the first replay failure across a chunk).-toDecider ::-  (BoolAlg phi (RegFile rs, ci), Eq co) =>-  SymTransducer phi rs s ci co ->-  Decider ci co (s, RegFile rs) (InFlight s co, RegFile rs)-toDecider t =-  Decider-    { decide = \cmd (s, regs) -> omega t s regs cmd,-      evolve = \(s, regs) ev -> case applyEvent t s regs ev of-        Just (s', regs') -> (s', regs')-        Nothing -> (s, regs),-      evolveStreaming = \(w, regs) ev -> applyEventStreaming t w regs ev,-      initialState = (initial t, initialRegs t),-      isTerminal = \(s, _regs) -> isFinal t s-    }
src/Keiki/Generics.hs view
@@ -314,7 +314,9 @@ -- | Derive an initial 'RegFile' for any slot list. Every slot is -- pre-bound to a deferred error tagged with the slot's name so reads -- of an uninitialized slot crash with a targeted message instead of--- a silent bottom.+-- a silent bottom. Every slot must be written before the register file is+-- read or encoded; 'emptyRegFile' is an initialization scaffold, not an+-- encodable snapshot. class EmptyRegFile (rs :: [Slot]) where   emptyRegFile :: RegFile rs @@ -328,7 +330,12 @@   emptyRegFile =     RCons       (Proxy @s)-      (error ("uninit: " ++ symbolVal (Proxy @s)))+      ( error+          ( "uninit: "+              ++ symbolVal (Proxy @s)+              ++ " (slot read before first write; a RegFile must be fully initialized before it is read or encoded)"+          )+      )       emptyRegFile  -- * Sum-walking machinery -------------------------------------------------
src/Keiki/Generics/TH.hs view
@@ -1,18 +1,10 @@ {-# LANGUAGE TemplateHaskell #-} --- \$('deriveAggregateCtors' \'\'UserCmd \'\'UserRegRegs---     [ ("StartRegistration",  "Start")---     , ("ConfirmAccount",     "Confirm")---     , ("ResendConfirmation", "Resend")---     , ("FulfillGDPRRequest", "Gdpr")---     , ("Continue",           "Continue")---     ])--- @+-- |+-- Module      : Keiki.Generics.TH+-- Description : Template Haskell splices for aggregate constructor plumbing. ----- expands to the same 14 declarations a hand-written module ships--- (4 record ctors × 3 decls + 1 singleton × 2 decls).---- | Template Haskell splices that retire the per-constructor authoring+-- These splices retire the per-constructor authoring -- boilerplate at the example layer. -- -- 'deriveAggregateCtors' emits, for each entry in its spec list, the@@ -53,7 +45,39 @@ --   | ResendConfirmation ResendConfirmationData --   | FulfillGDPRRequest FulfillGDPRRequestData --   | Continue---   deriving ('Eq', 'Show', 'GHC.Generics.Generic')+--   deriving (Eq, Show, Generic)+--+-- \$('deriveAggregateCtors' \'\'UserCmd \'\'UserRegRegs+--     [ ("StartRegistration",  "Start")+--     , ("ConfirmAccount",     "Confirm")+--     , ("ResendConfirmation", "Resend")+--     , ("FulfillGDPRRequest", "Gdpr")+--     , ("Continue",           "Continue")+--     ])+-- @+--+-- This expands to the same 14 declarations a hand-written module ships+-- (4 record constructors × 3 declarations + 1 singleton × 2 declarations).+--+-- The @*All@ and @*With@ enumeration variants skip unsupported GADT and+-- explicitly quantified constructors, emitting a compile-time warning that+-- names both the skipped constructor and the splice. Explicit spec-list+-- variants fail when asked to generate helpers for an unsupported shape.+--+-- == Negative-test procedure (manual)+--+-- A positional payload is classified as the command constructor's payload,+-- then rejected immediately because it is not a single record-syntax type:+--+-- @+-- data BadCmd = Placed Int+-- type BadRegs = '[]+-- \$(deriveAggregateCtors \'\'BadCmd \'\'BadRegs [("Placed", "Placed")])+-- @+--+-- Compiling that splice must fail with+-- @deriveAggregateCtors: requires a single record-syntax constructor on+-- payload GHC.Types.Int@. module Keiki.Generics.TH   ( deriveAggregateCtors,     deriveAggregateCtorsAll,@@ -134,6 +158,7 @@   Q [Dec] deriveAggregateCtorsAll cmdName regsName = do   ctors <- reifyCtors cmdName "deriveAggregateCtorsAll"+  warnSkippedConstructors "deriveAggregateCtorsAll" ctors   let ctorMap = [(nameBase n, c) | c <- ctors, n <- conNames c]       specs = [(nameBase n, nameBase n) | c <- ctors, n <- conNames c]   genAggregateCtors cmdName regsName ctorMap specs@@ -185,6 +210,7 @@   Q [Dec] deriveAggregateCtorsWith cmdName regsName opts = do   ctors <- reifyCtors cmdName "deriveAggregateCtorsWith"+  warnSkippedConstructors "deriveAggregateCtorsWith" ctors   let ctorMap = [(nameBase n, c) | c <- ctors, n <- conNames c]       allCtors = map fst ctorMap   specs <-@@ -224,6 +250,7 @@   Q [Dec] deriveWireCtorsAll evtName = do   ctors <- reifyCtors evtName "deriveWireCtorsAll"+  warnSkippedConstructors "deriveWireCtorsAll" ctors   let ctorMap = [(nameBase n, c) | c <- ctors, n <- conNames c]       specs = [(nameBase n, nameBase n) | c <- ctors, n <- conNames c]   genWireCtors evtName ctorMap specs@@ -267,6 +294,7 @@   Q [Dec] deriveWireCtorsWith evtName opts = do   ctors <- reifyCtors evtName "deriveWireCtorsWith"+  warnSkippedConstructors "deriveWireCtorsWith" ctors   let ctorMap = [(nameBase n, c) | c <- ctors, n <- conNames c]       allCtors = map fst ctorMap   specs <-@@ -284,10 +312,8 @@ -- each constructor's own name as its short-name suffix. -- -- @---- $('deriveAggregate' \'\'OrderCmd \'\'OrderCartRegs \'\'OrderEvent)+-- \$('deriveAggregate' \'\'OrderCmd \'\'OrderCartRegs \'\'OrderEvent) -- @- deriveAggregate ::   -- | command sum type, e.g. @\'\'OrderCmd@   Name ->@@ -314,8 +340,7 @@ -- == Worked invocation -- -- @---- $('deriveView' \'\'Vertex \'\'UserRegRegs+-- \$('deriveView' \'\'Vertex \'\'UserRegRegs --     "SUserVertex" "UserView" "userView" --     [ ("PotentialCustomer",    []) --     , ("Registering",          [])@@ -324,7 +349,6 @@ --     , ("Deleted",              ["email", "deletedAt"]) --     ]) -- @- deriveView ::   -- | vertex enum, e.g. @\'\'Vertex@   Name ->@@ -537,6 +561,36 @@ conNames (InfixC _ n _) = [n] conNames _ = [] +-- | Extract names from every Template Haskell constructor shape, including+-- shapes that keiki deliberately does not generate helpers for.+allConNames :: Con -> [Name]+allConNames (NormalC n _) = [n]+allConNames (RecC n _) = [n]+allConNames (InfixC _ n _) = [n]+allConNames (ForallC _ _ con) = allConNames con+allConNames (GadtC names _ _) = names+allConNames (RecGadtC names _ _) = names++-- | Enumeration splices skip unsupported GADT or explicitly quantified+-- constructors, but never silently. Explicit spec-list splices retain their+-- existing fail-fast behavior when a requested constructor is unsupported.+warnSkippedConstructors :: String -> [Con] -> Q ()+warnSkippedConstructors caller ctors =+  mapM_ warn skipped+  where+    skipped =+      [ name+      | con <- ctors,+        null (conNames con),+        name <- allConNames con+      ]+    warn name =+      reportWarning+        ( caller+            <> ": skipped unsupported GADT or explicitly quantified constructor "+            <> nameBase name+        )+ -- | Three-state classification of a constructor's payload. -- --   * @Just Nothing@  — singleton (zero-arg 'NormalC').@@ -682,6 +736,7 @@ recordDecls ::   Name -> Name -> String -> String -> Type -> Q [Dec] recordDecls cmdName regsName ctorStr shortStr payTy = do+  _ <- requireSingleRecordCtor "deriveAggregateCtors" payTy   let inCtorN = mkName ("inCtor" <> shortStr)       inpN = mkName ("inp" <> shortStr)       isN = mkName ("is" <> shortStr)@@ -844,18 +899,7 @@ -- unambiguous. genTermFieldsRecord :: String -> Type -> Q [Dec] genTermFieldsRecord shortStr payTy = do-  payName <- typeConstructorName payTy-  payInfo <- reify payName-  fields <- case payInfo of-    TyConI (DataD _ _ _ _ [RecC _ fs] _) -> pure fs-    TyConI (NewtypeD _ _ _ _ (RecC _ fs) _) -> pure fs-    _ ->-      fail $-        "deriveWireCtors: TermFields generation requires "-          <> "a single record-syntax constructor on payload "-          <> show payName-          <> ", got "-          <> show payInfo+  fields <- requireSingleRecordCtor "deriveWireCtors" payTy   let recName = mkName (shortStr <> "TermFields")   rsN <- newName "rs"   ciN <- newName "ci"@@ -910,15 +954,35 @@       instDec = InstanceD Nothing [] instHead [methodDef]   pure [recDataDec, instDec] +-- | Require a payload type to name a data or newtype with exactly one+-- record-syntax constructor. Shared by command projections and event+-- @TermFields@ generation so both sides reject invalid payloads at the splice+-- boundary with the same diagnostic shape.+requireSingleRecordCtor :: String -> Type -> Q [VarBangType]+requireSingleRecordCtor caller payTy = do+  payName <- typeConstructorName caller payTy+  payInfo <- reify payName+  case payInfo of+    TyConI (DataD _ _ _ _ [RecC _ fields] _) -> pure fields+    TyConI (NewtypeD _ _ _ _ (RecC _ fields) _) -> pure fields+    _ ->+      fail $+        caller+          <> ": requires a single record-syntax constructor on payload "+          <> show payName+          <> ", got "+          <> show payInfo+ -- | Extract a type's head constructor name. Accepts @ConT@ and the -- common forms it might wear after kind-elaboration; rejects -- function/forall/promoted shapes.-typeConstructorName :: Type -> Q Name-typeConstructorName (ConT n) = pure n-typeConstructorName (SigT t _) = typeConstructorName t-typeConstructorName other =+typeConstructorName :: String -> Type -> Q Name+typeConstructorName _ (ConT n) = pure n+typeConstructorName caller (SigT t _) = typeConstructorName caller t+typeConstructorName caller other =   fail $-    "deriveWireCtors: payload type must be a type constructor, "+    caller+      <> ": payload type must be a type constructor, "       <> "got "       <> show other 
src/Keiki/Internal/Slots.hs view
@@ -4,9 +4,11 @@ -- on 'Keiki.Core.Update' (EP-18 of MasterPlan 6). -- -- The keiki invariant is that slot names within a register file are--- pairwise distinct. EP-18 promotes that invariant from a runtime--- check (the old @combine@'s integer-position overlap test) to a--- type-level constraint by:+-- pairwise distinct. 'DistinctNames' enforces that invariant at builder+-- entry points. Lower-level 'HasIndexN' resolution still selects the+-- first matching occurrence, so callers that bypass those entry points+-- also bypass the distinctness check. EP-18 separately promotes update+-- target distinctness from a runtime check to a type-level constraint by: -- --   * indexing 'Keiki.Core.Update' over @(w :: [Symbol])@, the set of --     slot names the update writes; and@@ -23,6 +25,7 @@     Concat,     Member,     Disjoint,+    DistinctNames,      -- * Slot-name projection     Names,@@ -79,6 +82,29 @@           ':$$: 'Text "Each register slot may be written at most once per edge update."       ) +-- | Pairwise distinctness of one slot-name list. Reports the first+-- duplicated name. Builder entry points apply this to 'Names' of the+-- register schema; lower-level AST construction does not.+type family DistinctNames (xs :: [Symbol]) :: Constraint where+  DistinctNames '[] = ()+  DistinctNames (x ': xs) = (NotElemSlot x xs, DistinctNames xs)++type family NotElemSlot (x :: Symbol) (ys :: [Symbol]) :: Constraint where+  NotElemSlot _ '[] = ()+  NotElemSlot x (y ': ys) = (NotElemSlotCmp (CmpSymbol x y) x, NotElemSlot x ys)++type family NotElemSlotCmp (cmp :: Ordering) (x :: Symbol) :: Constraint where+  NotElemSlotCmp 'LT _ = ()+  NotElemSlotCmp 'GT _ = ()+  NotElemSlotCmp 'EQ x =+    TypeError+      ( 'Text "Keiki: register file declares slot \""+          ':<>: 'Text x+          ':<>: 'Text "\" more than once. "+          ':$$: 'Text "Slot names in a register file must be pairwise distinct; "+          ':$$: 'Text "a duplicated name silently shadows the later slot."+      )+ -- | Project the slot-name list out of a slot list. The kind -- @[(Symbol, Type)]@ is keiki's @[Slot]@ at the kind level (a synonym -- defined in 'Keiki.Core'); written here in unfolded form to avoid a@@ -96,8 +122,10 @@   IS :: IndexN s rs r -> IndexN s ('(s', r') ': rs) r  -- | Resolve a label @s@ against a slot list @rs@ to an 'IndexN' for--- the value at that slot. The functional dependency @s rs -> r@--- ensures that a label uniquely determines the slot's type.+-- the value at that slot. Resolution selects the first matching slot.+-- Builder-authored transducers enforce pairwise-distinct names with+-- 'DistinctNames', but lower-level callers can still supply a duplicate+-- schema and will observe this first-match behavior. class   HasIndexN (s :: Symbol) (rs :: [(Symbol, Type)]) (r :: Type)     | s rs -> r
src/Keiki/Profunctor.hs view
@@ -3,6 +3,7 @@ -- @Disjoint '[] '[]@ witness. GHC sees the @forall xs ys.@ as -- ambiguous because neither @xs@ nor @ys@ appear in the result; that -- is intentional — call sites pin them via 'TypeApplications'.+{-# LANGUAGE PatternSynonyms #-} {-# OPTIONS_GHC -Wno-redundant-constraints #-}  -- | Existential wrapper for 'SymTransducer' enabling participation in@@ -15,6 +16,48 @@ -- typeclasses ('Profunctor', 'Category', 'Strong', 'Choice', 'Arrow') -- expect. --+-- Stability: experimental. The wrapper and its categorical instances may+-- change before the law contract is resolved. Concrete checked composition+-- through 'Keiki.Composition.composeChecked' is the supported validation+-- boundary for aggregate pipelines.+--+-- == Law status: forward fragment versus inversion/replay+--+-- /Forward equivalence/ means that 'Keiki.Core.delta', 'Keiki.Core.omega',+-- and 'Keiki.Core.step' agree for every command sequence, comparing control+-- states up to the documented state isomorphism. /Inversion equivalence/ adds+-- agreement of 'Keiki.Core.solveOutput', streaming replay, and+-- reconstitution. Because replay is public and central to keiki, a law that+-- holds only forward is called a /forward fragment/, not an unqualified law.+--+-- * 'Profunctor'/'Functor': identity and composition hold on tested multi-step+--   forward traces. They fail inversion equivalence because input maps poison+--   @icBuild@ and output maps poison @wcMatch@. Mapped names are stamped with+--   @#lmapped@/@#rmapped@; arm predicates lowered by contramap use the same+--   marker and are symbolically conservative.+-- * 'Category': the identity sentinel is definitional, and stateful forward+--   associativity is tested after the real witness and snapshot fixes.+--   Non-identity composition is partial: slot overlap raises+--   'CategoryOverlapError', and a mapped boundary raises+--   'PoisonedCompositionError'. It is therefore not an unqualified lawful+--   'Category' over all public values.+-- * 'Choice': 'left''/'right'' preserve forward routing and replay for a+--   replayable stateful transducer on tested multi-step traces; they do not+--   repair an underlying transducer's inversion defect. Real+--   'PLeftArm'/'PRightArm' guards make the arms disjoint even for+--   epsilon/register-only underlying guards.+-- * 'Strong': 'first''/'second'' preserve the threaded value and forward+--   state evolution, but fail inversion equivalence because their paired+--   descriptors are not invertible. A Strong-produced boundary is poisoned.+-- * 'Arrow': standalone 'Arr.arr' has forward function behavior but is not+--   replay-invertible. Fusion fails: @arr f >>> arr g@ raises+--   'PoisonedCompositionError' rather than equalling @arr (g . f)@.+--+-- Deferred API choices (recorded 2026-07-12) are a forward-only wrapper, a+-- separate replay-safe/isomorphism capability, a total internal category, or+-- selective instance removal. This module selects none of them; all existing+-- instances remain available while the experimental contract is evaluated.+-- -- See @docs/plans/27-existential-wrapper-for-symtransducer-plus-profunctor-instance-and-variance-combinators.md@ -- for the design rationale and the documented variance caveat: -- transducers produced by 'lmapCi' / 'rmapCo' / 'dimapTransducer' /@@ -36,8 +79,10 @@ -- cannot be discharged by GHC at the wrapper boundary. module Keiki.Profunctor   ( -- * Existential wrapper-    SomeSymTransducer (..),+    SomeSymTransducer (SomeSymIdentity),+    pattern SomeSymTransducer,     someSymTransducer,+    PoisonProvenance (..),      -- * Standalone variance combinators on the concrete 'SymTransducer'     lmapCi,@@ -54,6 +99,7 @@      -- * Category-instance overlap exception     CategoryOverlapError (..),+    PoisonedCompositionError (..),   ) where @@ -62,9 +108,16 @@ import Control.Exception (Exception, throw) import Data.Profunctor (Choice (..), Profunctor (..), Strong (..)) import Data.Proxy (Proxy (..))-import Keiki.Composition (WeakenR, alternative, compose)+import Keiki.Composition+  ( KnownSlots (..),+    SlotListWitness (WNil),+    alternative,+    appendWitness,+    compose,+    withDisjointNil,+    withKnownSlots,+  ) import Keiki.Core-import Keiki.Generics (Append) import Unsafe.Coerce (unsafeCoerce)  -- | Existential wrapper hiding @rs@ (register-file slot list) and@@ -72,11 +125,12 @@ -- output alphabet @co@. Predicate carrier is fixed to 'HsPred' since -- "Keiki.Composition"'s combinators are pinned to that carrier. ----- The packed constraints @WeakenR rs@ and @KnownSlotNames rs@ are--- needed by the 'Cat.Category' instance: 'compose' demands--- @WeakenR rs1@, and the runtime slot-overlap check that guards--- 'Cat..' reads each transducer's slot names at the value level--- via @KnownSlotNames@. The packed constraints @Bounded s@ and+-- The packed 'KnownSlots' constraint supplies a value-level witness+-- for the hidden slot-list spine and bundles 'WeakenR' and+-- 'KnownSlotNames' as superclasses. Composite wrappers append the+-- real witnesses and re-derive all three dictionaries by structural+-- induction; no method-carrying dictionary is fabricated. The+-- packed constraints @Bounded s@ and -- @Enum s@ let pattern-matched-out transducers participate in the -- symbolic analyses ('Keiki.Symbolic.isSingleValuedSym', -- 'Keiki.Core.checkHiddenInputs'), which both enumerate the vertex@@ -102,35 +156,64 @@ -- 'compose'. See 'identityTransducer' for the concrete-identity -- transducer that some non-Category code paths still want. ----- Pattern-match on the constructor to recover the underlying+-- Pattern-match on the compatibility pattern to recover the underlying -- 'SymTransducer' (the @rs@ and @s@ variables come into scope as -- skolem types — they may not escape the pattern match). Handle -- 'SomeSymIdentity' explicitly when traversing arbitrary -- 'SomeSymTransducer' values.+--+-- | Tracks whether an existential wrapper's input or output descriptor has+-- been rewritten in a way that is unsafe to cross at a composition boundary.+data PoisonProvenance = PoisonProvenance+  { poisonedInput :: !Bool,+    poisonedOutput :: !Bool+  }+  deriving stock (Eq, Show)++cleanProvenance :: PoisonProvenance+cleanProvenance = PoisonProvenance False False++-- | A concrete symbolic transducer with its register and vertex types hidden,+-- or the definitional identity sentinel used by the 'Cat.Category' instance. data SomeSymTransducer ci co where-  SomeSymTransducer ::-    ( WeakenR rs,-      KnownSlotNames rs,+  SomeSymTransducerWith ::+    ( KnownSlots rs,       Bounded s,       Enum s     ) =>+    PoisonProvenance ->     SymTransducer (HsPred rs ci) rs s ci co ->     SomeSymTransducer ci co   SomeSymIdentity :: SomeSymTransducer a a +-- | Compatibility construction and match pattern for a concrete wrapper.+-- Direct construction starts with honest input/output alphabets; internal+-- instance operations retain provenance through @SomeSymTransducerWith@.+pattern SomeSymTransducer ::+  forall ci co.+  () =>+  forall rs s.+  (KnownSlots rs, Bounded s, Enum s) =>+  SymTransducer (HsPred rs ci) rs s ci co ->+  SomeSymTransducer ci co+pattern SomeSymTransducer t <- SomeSymTransducerWith _ t+  where+    SomeSymTransducer t = SomeSymTransducerWith cleanProvenance t++{-# COMPLETE SomeSymTransducer, SomeSymIdentity #-}+ -- | Smart constructor: lift a concrete 'SymTransducer' into the -- wrapper. Equivalent to applying the data constructor; provided for -- naming consistency with the rest of @Keiki.Profunctor@'s exports -- and for users who prefer functions over constructors. someSymTransducer ::-  ( WeakenR rs,-    KnownSlotNames rs,+  ( KnownSlots rs,     Bounded s,     Enum s   ) =>   SymTransducer (HsPred rs ci) rs s ci co ->   SomeSymTransducer ci co-someSymTransducer = SomeSymTransducer+someSymTransducer = SomeSymTransducerWith cleanProvenance  -- * Standalone variance combinators --------------------------------------- @@ -138,7 +221,7 @@ -- 'InCtor' inside the transducer's guards / updates / outputs and -- replaces each with one whose 'icMatch' is precomposed with @f@. ----- /Variance caveat:/ the rewritten 'InCtor's 'icBuild' is poisoned+-- /Variance caveat (see "Law status" above):/ the rewritten 'InCtor's 'icBuild' is poisoned -- (raises a runtime error if invoked) — callers must not invoke -- 'Keiki.Core.solveOutput' on edges produced by this combinator. The -- forward evaluation path ('Keiki.Core.evalPred',@@ -163,7 +246,7 @@ -- structural 'PInCtor' check, effectively filtering them out of the -- transducer's command stream. ----- /Variance caveat:/ same as 'lmapCi' — 'Keiki.Core.solveOutput' is+-- /Variance caveat (see "Law status" above):/ same as 'lmapCi' — 'Keiki.Core.solveOutput' is -- not preserved. lmapMaybeCi ::   forall ci ci' rs s co.@@ -182,7 +265,7 @@ -- every 'WireCtor' inside the transducer's outputs and replaces each -- with one whose 'wcBuild' is post-composed with @g@. ----- /Variance caveat:/ the rewritten 'WireCtor's 'wcMatch' is set to+-- /Variance caveat (see "Law status" above):/ the rewritten 'WireCtor's 'wcMatch' is set to -- @const Nothing@ — 'Keiki.Core.solveOutput' on rewritten edges -- returns 'Nothing'. The forward output construction (which only -- uses 'wcBuild') is unaffected.@@ -200,7 +283,7 @@     }  -- | Bidirectional map on input and output alphabets. Equivalent to--- @'rmapCo' g . 'lmapCi' f@. /Variance caveat/ as both 'lmapCi' and+-- @'rmapCo' g . 'lmapCi' f@. /Variance caveat (see "Law status" above)/ as both 'lmapCi' and -- 'rmapCo': 'Keiki.Core.solveOutput' is not preserved on the result. dimapTransducer ::   (ci' -> ci) ->@@ -221,18 +304,30 @@ -- 'identityTransducer' wrap before the variance combinators run, so -- the @ci@/@co@ rewrites apply uniformly. instance Profunctor SomeSymTransducer where-  dimap f g (SomeSymTransducer t) =-    SomeSymTransducer (dimapTransducer f g t)+  dimap f g (SomeSymTransducerWith provenance t) =+    SomeSymTransducerWith+      provenance {poisonedInput = True, poisonedOutput = True}+      (dimapTransducer f g t)   dimap f g SomeSymIdentity =-    SomeSymTransducer (dimapTransducer f g identityTransducer)-  lmap f (SomeSymTransducer t) =-    SomeSymTransducer (lmapCi f t)+    SomeSymTransducerWith+      (PoisonProvenance True True)+      (dimapTransducer f g identityTransducer)+  lmap f (SomeSymTransducerWith provenance t) =+    SomeSymTransducerWith+      provenance {poisonedInput = True}+      (lmapCi f t)   lmap f SomeSymIdentity =-    SomeSymTransducer (lmapCi f identityTransducer)-  rmap g (SomeSymTransducer t) =-    SomeSymTransducer (rmapCo g t)+    SomeSymTransducerWith+      (PoisonProvenance True False)+      (lmapCi f identityTransducer)+  rmap g (SomeSymTransducerWith provenance t) =+    SomeSymTransducerWith+      provenance {poisonedOutput = True}+      (rmapCo g t)   rmap g SomeSymIdentity =-    SomeSymTransducer (rmapCo g identityTransducer)+    SomeSymTransducerWith+      (PoisonProvenance False True)+      (rmapCo g identityTransducer)  -- | 'Functor' on the output alphabet. @'fmap' = 'rmap'@. instance Functor (SomeSymTransducer ci) where@@ -345,18 +440,30 @@  instance Exception CategoryOverlapError +-- | Exception raised when categorical composition would cross a boundary+-- rewritten by a non-invertible input/output map. The boundary is rejected+-- before name substitution can silently bypass the map.+data PoisonedCompositionError = PoisonedCompositionError+  { pceSide :: String,+    pceDetail :: String+  }+  deriving stock (Eq, Show)++instance Exception PoisonedCompositionError+ -- | A constraint dictionary for @'Disjoint' xs ys@. Used together -- with 'unsafeCoerceDisjointness' to smuggle the constraint into -- scope after a value-level overlap check. data DictDisjoint xs ys where   DictDisjoint :: (Disjoint xs ys) => DictDisjoint xs ys --- | Fabricate a 'DictDisjoint' for arbitrary @xs@ and @ys@. The--- only safe call site is the body of 'Cat..' on--- 'SomeSymTransducer', after the value-level check has confirmed--- the slot lists are disjoint. The 'CategoryOverlapError' exception--- raised on overlap is the *only* safety net; calling this without--- a prior check can produce a semantically broken composite.+-- | Fabricate a 'DictDisjoint' for arbitrary @xs@ and @ys@. This is+-- the only fabricated dictionary in this module, and its only call+-- site is the body of 'Cat..' after the value-level check has+-- confirmed the slot lists are disjoint. Composite wrappers carry+-- real 'KnownSlots' witnesses, so the checked names remain accurate+-- under arbitrary nesting. Calling this without that check can+-- produce a semantically broken composite. -- -- Implementation: @'Disjoint' '[] '[]@ reduces to the trivially-true -- constraint @()@, so @DictDisjoint @'[] @'[]@ is always@@ -368,33 +475,6 @@ unsafeCoerceDisjointness =   unsafeCoerce (DictDisjoint :: DictDisjoint '[] '[]) --- | A constraint dictionary witnessing that a slot list satisfies--- the two structural classes the wrapper packs. Used together with--- 'unsafeCoerceWrapperDict' to wrap a freshly-composed--- @SymTransducer ... (Append rs1 rs2) ...@ back into--- 'SomeSymTransducer' when 'GHC' cannot reduce--- @WeakenR (Append rs1 rs2)@ / @KnownSlotNames (Append rs1 rs2)@--- (because the spines @rs1@ and @rs2@ are skolems).-data DictWrapper rs where-  DictWrapper :: (WeakenR rs, KnownSlotNames rs) => DictWrapper rs---- | Fabricate a 'DictWrapper' for an arbitrary slot list. Both--- 'WeakenR' and 'KnownSlotNames' are structural classes with--- automatic instances for every concrete @[Slot]@: whenever both--- @rs1@ and @rs2@ have these instances, so does @'Append' rs1 rs2@--- (provable by induction on @rs1@'s spine, which we cannot perform--- without a value-level witness — hence the 'unsafeCoerce').------ Safe at the 'Cat..' call site because both inner transducers'--- packed @WeakenR@ + @KnownSlotNames@ constraints already hold for--- @rs1@ and @rs2@ individually, and the composite slot list--- @'Append' rs1 rs2@ inherits the structural property.-unsafeCoerceWrapperDict ::-  forall rs.-  DictWrapper rs-unsafeCoerceWrapperDict =-  unsafeCoerce (DictWrapper :: DictWrapper '[])- -- * Category instance --------------------------------------------------  -- | Standard 'Control.Category.Category' instance.@@ -411,13 +491,13 @@ -- 'Keiki.Composition.compose'. The wrapper hides @rs@, so -- @compose@'s static @Disjoint (Names rs1) (Names rs2)@ constraint -- cannot be discharged by GHC; instead, the operator reads each--- transducer's slot names at the value level via 'KnownSlotNames',+-- transducer's slot names at the value level via 'KnownSlots', -- checks for overlap, and either: -- --   * raises 'CategoryOverlapError' (synchronously, on overlap), or---   * uses 'unsafeCoerceDisjointness' to fabricate the constraint---     evidence and calls 'compose' with the existential @rs@'s---     restored as the composite @'Append' rs1 rs2@.+--   * uses 'unsafeCoerceDisjointness' for the methodless constraint,+--     calls 'compose', and re-derives the composite's method-carrying+--     dictionaries from the appended slot-list witnesses. -- -- /Why a sentinel rather than a real identity transducer:/ -- 'Keiki.Composition.compose' substitutes t2's @TInpCtorField ic2@@@ -436,8 +516,8 @@    SomeSymIdentity . t = t   t . SomeSymIdentity = t-  SomeSymTransducer t2 . SomeSymTransducer t1 =-    composeWrappers t1 t2+  SomeSymTransducerWith provenance2 t2 . SomeSymTransducerWith provenance1 t1 =+    composeWrappers provenance1 t1 provenance2 t2  -- | Compose two existentially-packed transducers, performing the -- runtime overlap check that 'Cat..' delegates to. Factored out so@@ -446,27 +526,51 @@ -- their own, name them in a form usable inside 'TypeApplications'). composeWrappers ::   forall rs1 rs2 s1 s2 ci mid co.-  ( WeakenR rs1,-    KnownSlotNames rs1,-    KnownSlotNames rs2,+  ( KnownSlots rs1,+    KnownSlots rs2,     Bounded s1,     Enum s1,     Bounded s2,     Enum s2   ) =>+  PoisonProvenance ->   SymTransducer (HsPred rs1 ci) rs1 s1 ci mid ->+  PoisonProvenance ->   SymTransducer (HsPred rs2 mid) rs2 s2 mid co ->   SomeSymTransducer ci co-composeWrappers t1 t2 =+composeWrappers provenance1 t1 provenance2 t2 =   let names1 = slotNames @rs1       names2 = slotNames @rs2       overlap = filter (`elem` names2) names1-   in if not (null overlap)-        then throw (CategoryOverlapError overlap)-        else case unsafeCoerceDisjointness @(Names rs1) @(Names rs2) of+      boundaryPoison+        | poisonedOutput provenance1 =+            Just+              ( PoisonedCompositionError+                  "upstream output"+                  "the upstream wrapper was produced by rmap/dimap/first/arr; move the output map outside the composition (see Law status)"+              )+        | poisonedInput provenance2 =+            Just+              ( PoisonedCompositionError+                  "downstream input"+                  "the downstream wrapper was produced by lmap/dimap/first; move the input map outside the composition (see Law status)"+              )+        | otherwise = Nothing+   in case boundaryPoison of+        Just err -> throw err+        Nothing | not (null overlap) -> throw (CategoryOverlapError overlap)+        Nothing -> case unsafeCoerceDisjointness @(Names rs1) @(Names rs2) of           DictDisjoint ->-            case unsafeCoerceWrapperDict @(Append rs1 rs2) of-              DictWrapper -> SomeSymTransducer (compose t1 t2)+            withKnownSlots+              (appendWitness (slotWitness @rs1) (slotWitness @rs2))+              ( SomeSymTransducerWith+                  ( PoisonProvenance+                      { poisonedInput = poisonedInput provenance1,+                        poisonedOutput = poisonedOutput provenance2+                      }+                  )+                  (compose t1 t2)+              )  -- * Choice instance ---------------------------------------------------- @@ -483,13 +587,10 @@ -- -- /No slot-name overlap risk:/ 'identityTransducer' has @rs = '[]@, -- so the @'Disjoint' (Names rs) (Names '[])@ side condition on--- 'alternative' reduces to a vacuous constraint — no--- 'CategoryOverlapError' path is needed (unlike 'Cat..'). The--- 'unsafeCoerceDisjointness' call below fabricates the constraint--- evidence purely because GHC cannot reduce the type family with--- @rs@ being a skolem; the underlying claim--- (@Disjoint xs '[]@ is always vacuously true) is sound by the--- definition of 'Disjoint' in "Keiki.Internal.Slots".+-- 'alternative' is proved by induction over the packed 'KnownSlots'+-- witness — no 'CategoryOverlapError' path and no coercion are needed.+-- For 'right'', the left register list is empty, so both append and+-- disjointness reduce definitionally and the original witness applies. -- -- /Sentinel handling:/ when the input is the 'SomeSymIdentity' -- sentinel, both @'left''@ and @'right''@ return 'SomeSymIdentity' —@@ -497,7 +598,7 @@ -- of identity. The Choice law @left' Cat.id = Cat.id@ holds *by -- construction* on the wrapper. ----- /Variance caveat:/ inherits 'Keiki.Composition.alternative''s+-- /Variance caveat (see "Law status" above):/ inherits 'Keiki.Composition.alternative''s -- mechanical-inversion preservation: @solveOutput@ on edges produced -- by @left'@ / @right'@ runs the underlying alternative's -- @leftInCtor@ / @rightInCtor@ wrappers, which preserve round-trip@@ -510,52 +611,47 @@     SomeSymTransducer a b ->     SomeSymTransducer (Either a c) (Either b c)   left' SomeSymIdentity = SomeSymIdentity-  left' (SomeSymTransducer t) = leftWrap t+  left' (SomeSymTransducerWith provenance t) = leftWrap provenance t    right' ::     forall a b c.     SomeSymTransducer a b ->     SomeSymTransducer (Either c a) (Either c b)   right' SomeSymIdentity = SomeSymIdentity-  right' (SomeSymTransducer t) = rightWrap t+  right' (SomeSymTransducerWith provenance t) = rightWrap provenance t  -- | Helper for 'left'' on a wrapped concrete transducer. Factored out -- to bind the existentially-packed @rs@ and @s@ to named type--- variables so 'TypeApplications' on 'unsafeCoerceDisjointness' /--- 'unsafeCoerceWrapperDict' can reach them.+-- variables for the witness induction. leftWrap ::   forall rs s ci co c.-  ( WeakenR rs,-    KnownSlotNames rs,+  ( KnownSlots rs,     Bounded s,     Enum s   ) =>+  PoisonProvenance ->   SymTransducer (HsPred rs ci) rs s ci co ->   SomeSymTransducer (Either ci c) (Either co c)-leftWrap t =-  case unsafeCoerceDisjointness @(Names rs) @(Names '[]) of-    DictDisjoint ->-      case unsafeCoerceWrapperDict @(Append rs '[]) of-        DictWrapper ->-          SomeSymTransducer (alternative t (identityTransducer @c))+leftWrap provenance t =+  let w = slotWitness @rs+   in withDisjointNil w $+        withKnownSlots+          (appendWitness w WNil)+          (SomeSymTransducerWith provenance (alternative t (identityTransducer @c)))  -- | Helper for 'right'' on a wrapped concrete transducer. Symmetric -- to 'leftWrap'. rightWrap ::   forall rs s ci co c.-  ( WeakenR rs,-    KnownSlotNames rs,+  ( KnownSlots rs,     Bounded s,     Enum s   ) =>+  PoisonProvenance ->   SymTransducer (HsPred rs ci) rs s ci co ->   SomeSymTransducer (Either c ci) (Either c co)-rightWrap t =-  case unsafeCoerceDisjointness @(Names '[]) @(Names rs) of-    DictDisjoint ->-      case unsafeCoerceWrapperDict @(Append '[] rs) of-        DictWrapper ->-          SomeSymTransducer (alternative (identityTransducer @c) t)+rightWrap provenance t =+  SomeSymTransducerWith provenance (alternative (identityTransducer @c) t)  -- * Strong instance ---------------------------------------------------- @@ -576,7 +672,7 @@ --     'WireCtor' with one that consumes @(c, fs)@ and produces --     @(co, c)@ — @\\(c, fs) -> (wcBuild wc fs, c)@. ----- /Variance caveat:/ same lossy-@solveOutput@ contract as 'lmapCi' /+-- /Variance caveat (see "Law status" above):/ same lossy-@solveOutput@ contract as 'lmapCi' / -- 'rmapCo'. The contramapped 'InCtor's 'icBuild' is poisoned, the -- new 'WireCtor's 'wcMatch' is @const Nothing@, and 'pairSndInCtor''s -- 'icBuild' is poisoned. Forward processing@@ -683,7 +779,7 @@ -- rewrites for ~10% better build cost, but the current shape keeps -- the symmetry obvious and the implementation small. ----- /Variance caveat:/ inherits 'firstSym''s lossy-@solveOutput@+-- /Variance caveat (see "Law status" above):/ inherits 'firstSym''s lossy-@solveOutput@ -- contract. instance Strong SomeSymTransducer where   first' ::@@ -691,15 +787,19 @@     SomeSymTransducer a b ->     SomeSymTransducer (a, c) (b, c)   first' SomeSymIdentity = SomeSymIdentity-  first' (SomeSymTransducer t) = SomeSymTransducer (firstSym t)+  first' (SomeSymTransducerWith provenance t) =+    SomeSymTransducerWith+      provenance {poisonedInput = True, poisonedOutput = True}+      (firstSym t)    second' ::     forall a b c.     SomeSymTransducer a b ->     SomeSymTransducer (c, a) (c, b)   second' SomeSymIdentity = SomeSymIdentity-  second' (SomeSymTransducer t) =-    SomeSymTransducer+  second' (SomeSymTransducerWith provenance t) =+    SomeSymTransducerWith+      provenance {poisonedInput = True, poisonedOutput = True}       (lmapCi swap (rmapCo swap (firstSym t)))     where       swap :: forall x y. (x, y) -> (y, x)@@ -716,13 +816,13 @@ -- — see 'identityTransducer' for the same lesson); the edge's -- 'WireCtor's 'wcBuild' applies @f@ to the read input. ----- /Variance caveat:/ same lossy-@solveOutput@ contract as+-- /Variance caveat (see "Law status" above):/ same lossy-@solveOutput@ contract as -- 'lmapCi' / 'rmapCo' / 'firstSym'. The 'WireCtor's 'wcMatch' is -- @const Nothing@ — there is no inverse function in general. -- Forward processing ('Keiki.Core.delta', 'Keiki.Core.omega') is -- unaffected. ----- /Composition limitation:/ 'Keiki.Composition.compose' substitutes+-- /Composition limitation (see "Law status" above):/ 'Keiki.Composition.compose' substitutes -- t2's 'TInpCtorField' against t1's 'WireCtor'-emitted output and -- demands 'icName ic2 == wcName wc1'. An 'arrTransducer'-produced -- transducer's 'WireCtor' is named @"arr"@ but the next stage's@@ -786,7 +886,10 @@ -- 'arr f >>> arr g' applies — see 'arrTransducer' for the full -- caveat. instance Arr.Arrow SomeSymTransducer where-  arr f = SomeSymTransducer (arrTransducer f)+  arr f =+    SomeSymTransducerWith+      (PoisonProvenance False True)+      (arrTransducer f)   first = first'   second = second' @@ -803,7 +906,7 @@ contraInCtor :: (ci' -> ci) -> InCtor ci ifs -> InCtor ci' ifs contraInCtor f InCtor {icName = n, icMatch = m} =   InCtor-    { icName = n,+    { icName = n <> "#lmapped",       icMatch = m . f,       icBuild = poisonedIcBuild n     }@@ -814,7 +917,7 @@ contraMaybeInCtor :: (ci' -> Maybe ci) -> InCtor ci ifs -> InCtor ci' ifs contraMaybeInCtor f InCtor {icName = n, icMatch = m} =   InCtor-    { icName = n,+    { icName = n <> "#lmapped",       icMatch = \ci' -> f ci' >>= m,       icBuild = poisonedIcBuild n     }@@ -834,7 +937,7 @@ mapWireCtor :: (co -> co') -> WireCtor co fs -> WireCtor co' fs mapWireCtor g WireCtor {wcName = n, wcBuild = b} =   WireCtor-    { wcName = n,+    { wcName = n <> "#rmapped",       wcMatch = \_co' -> Nothing,       wcBuild = g . b     }@@ -876,6 +979,8 @@     go (PNot p) = PNot (go p)     go (PEq a b) = PEq (contraTerm f a) (contraTerm f b)     go (PInCtor ic) = PInCtor (contraInCtor f ic)+    go PLeftArm = PInCtor (mappedArmInCtor (Just . f) True)+    go PRightArm = PInCtor (mappedArmInCtor (Just . f) False)     go (PCmp op a b) = PCmp op (contraTerm f a) (contraTerm f b)  contraMaybePred :: forall ci ci' rs. (ci' -> Maybe ci) -> HsPred rs ci -> HsPred rs ci'@@ -889,7 +994,23 @@     go (PNot p) = PNot (go p)     go (PEq a b) = PEq (contraMaybeTerm f a) (contraMaybeTerm f b)     go (PInCtor ic) = PInCtor (contraMaybeInCtor f ic)+    go PLeftArm = PInCtor (mappedArmInCtor f True)+    go PRightArm = PInCtor (mappedArmInCtor f False)     go (PCmp op a b) = PCmp op (contraMaybeTerm f a) (contraMaybeTerm f b)++mappedArmInCtor ::+  (ci' -> Maybe (Either ci1 ci2)) ->+  Bool ->+  InCtor ci' '[]+mappedArmInCtor f wantLeft =+  InCtor+    { icName = if wantLeft then "keiki#leftArm#lmapped" else "keiki#rightArm#lmapped",+      icMatch = \ci' -> case f ci' of+        Just (Left _) | wantLeft -> Just RNil+        Just (Right _) | not wantLeft -> Just RNil+        _ -> Nothing,+      icBuild = poisonedIcBuild (if wantLeft then "keiki#leftArm#lmapped" else "keiki#rightArm#lmapped")+    }  -- ** Update ------------------------------------------------------------- 
src/Keiki/Render/Inspector.hs view
@@ -212,6 +212,8 @@     go (PNot p) = T.pack "PNot" : go p     go (PEq _ _) = [T.pack "PEq"]     go (PInCtor _) = [T.pack "PInCtor"]+    go PLeftArm = [T.pack "PLeftArm"]+    go PRightArm = [T.pack "PRightArm"]     go (PCmp c _ _) = [T.pack "PCmp " <> T.pack (show c)]  -- | Recover the names of the slots an edge's 'Update' writes.
src/Keiki/Render/Mermaid.hs view
@@ -786,6 +786,8 @@     walk (PNot p) = walk p     walk PTop = Nothing     walk PBot = Nothing+    walk PLeftArm = Nothing+    walk PRightArm = Nothing     walk (PEq _ _) = Nothing     walk (PCmp {}) = Nothing @@ -959,6 +961,8 @@     go (PNot p) = T.pack "PNot" : go p     go (PEq _ _) = [T.pack "PEq"]     go (PInCtor _) = [T.pack "PInCtor"]+    go PLeftArm = [T.pack "PLeftArm"]+    go PRightArm = [T.pack "PRightArm"]     go (PCmp c _ _) = [T.pack "PCmp " <> T.pack (show c)]  -- | Assemble several already-rendered Mermaid diagrams into one
src/Keiki/Render/Pretty.hs view
@@ -81,6 +81,8 @@ prettyPred (PNot p) = T.pack "!(" <> prettyPred p <> T.pack ")" prettyPred (PEq l r) = prettyTerm l <> T.pack " == " <> prettyTerm r prettyPred (PInCtor ic) = T.pack (icName ic)+prettyPred PLeftArm = T.pack "Left"+prettyPred PRightArm = T.pack "Right" prettyPred (PCmp c l r) =   prettyTerm l <> T.pack " " <> cmpSym c <> T.pack " " <> prettyTerm r   where
src/Keiki/Render/Validate.hs view
@@ -1,3 +1,5 @@+{-# OPTIONS_GHC -Wno-partial-fields #-}+ -- | Pure, cheap structural-heuristic validators for rendered keiki -- Mermaid diagrams and Mermaid atlas documents. --@@ -14,6 +16,9 @@ -- rather than a 'Keiki.Core.SymTransducer', so there is no shared code. -- -- See @docs/plans/66-pure-mermaid-diagram-and-atlas-validation-helpers.md@.+-- The warning type intentionally exposes constructor-specific record fields;+-- callers are expected to pattern-match on its constructors before reading+-- them. module Keiki.Render.Validate   ( MermaidValidationOptions (..),     defaultMermaidValidationOptions,@@ -23,7 +28,6 @@   ) where -import Data.List (foldl') import Data.Map.Strict qualified as Map import Data.Text (Text) import Data.Text qualified as T
src/Keiki/Shape.hs view
@@ -3,9 +3,11 @@ -- | The /shape hash/ for @RegFile rs@. -- -- A snapshot persister (see keiro's @StateCodec (s, RegFile rs)@) needs a--- compact, GHC-upgrade-safe discriminator for the type-level slot list.--- 'regFileShapeHash' provides it: a SHA-256 of a canonical, deterministic--- rendering of every slot's name and type.+-- compact discriminator for the type-level slot list. 'regFileShapeHash'+-- provides it: a SHA-256 of a canonical, deterministic rendering of every+-- slot's name and type. Built-in scalar and container names are pinned to+-- Haskell-source spellings, so GHC's internal module reorganizations do not+-- invalidate their hashes. -- -- The hash is sensitive to structural changes (slot rename / addition / -- removal / reordering / type change) and insensitive to incidental@@ -14,6 +16,11 @@ -- §3 R3–R5 for the contract and §4 for the schema-evolution cases the -- hash catches. --+-- User-defined types that use the 'CanonicalTypeName' default retain their+-- defining module in the canonical name and are stable only while that module+-- path remains stable. Override 'canonicalTypeName' to pin an application-owned+-- name when that stronger guarantee is required.+-- -- This module is the keiki-side primitive. The JSON codec lives in the -- sibling package @keiki-codec-json@. Together they are the two halves -- of the snapshot story: the hash discriminates eligible snapshots; the@@ -65,7 +72,9 @@ -- runtime representation; users with stability concerns (a slot type -- whose defining module is likely to be renamed, or a slot type that -- straddles libraries with unstable module layouts) can override and--- pin the name explicitly.+-- pin the name explicitly. Built-in containers resolve each argument through+-- this class, so an override for @Foo@ also appears inside @Maybe Foo@,+-- @[Foo]@, @Either Foo b@, and tuples. -- -- See P9 in EP-36 (@docs\/plans\/36-regfile-json-codec-and-shape-hash-for-snapshot-persistence.md@). class CanonicalTypeName a where@@ -76,58 +85,110 @@ -- ** Built-in instances ------------------------------------------------------  ----- Default instances for the common scalar and primitive container types--- that a typical 'RegFile' carries. Each is empty-bodied, picking up the--- 'Typeable' default. Users who want a custom canonical name override--- with a non-empty instance.+-- Pinned instances for the common scalar and primitive container types that a+-- typical 'RegFile' carries. These names deliberately contain no defining+-- module path. User-defined instances may use the 'Typeable' default or supply+-- an equally explicit application-owned name. -instance CanonicalTypeName ()+instance CanonicalTypeName () where+  canonicalTypeName _ = T.pack "()" -instance CanonicalTypeName Bool+instance CanonicalTypeName Bool where+  canonicalTypeName _ = T.pack "Bool" -instance CanonicalTypeName Char+instance CanonicalTypeName Char where+  canonicalTypeName _ = T.pack "Char" -instance CanonicalTypeName Int+instance CanonicalTypeName Int where+  canonicalTypeName _ = T.pack "Int" -instance CanonicalTypeName Int8+instance CanonicalTypeName Int8 where+  canonicalTypeName _ = T.pack "Int8" -instance CanonicalTypeName Int16+instance CanonicalTypeName Int16 where+  canonicalTypeName _ = T.pack "Int16" -instance CanonicalTypeName Int32+instance CanonicalTypeName Int32 where+  canonicalTypeName _ = T.pack "Int32" -instance CanonicalTypeName Int64+instance CanonicalTypeName Int64 where+  canonicalTypeName _ = T.pack "Int64" -instance CanonicalTypeName Integer+instance CanonicalTypeName Integer where+  canonicalTypeName _ = T.pack "Integer" -instance CanonicalTypeName Word+instance CanonicalTypeName Word where+  canonicalTypeName _ = T.pack "Word" -instance CanonicalTypeName Word8+instance CanonicalTypeName Word8 where+  canonicalTypeName _ = T.pack "Word8" -instance CanonicalTypeName Word16+instance CanonicalTypeName Word16 where+  canonicalTypeName _ = T.pack "Word16" -instance CanonicalTypeName Word32+instance CanonicalTypeName Word32 where+  canonicalTypeName _ = T.pack "Word32" -instance CanonicalTypeName Word64+instance CanonicalTypeName Word64 where+  canonicalTypeName _ = T.pack "Word64" -instance CanonicalTypeName Double+instance CanonicalTypeName Double where+  canonicalTypeName _ = T.pack "Double" -instance CanonicalTypeName Float+instance CanonicalTypeName Float where+  canonicalTypeName _ = T.pack "Float" -instance CanonicalTypeName Text+instance CanonicalTypeName Text where+  canonicalTypeName _ = T.pack "Text" -instance CanonicalTypeName UTCTime+instance CanonicalTypeName UTCTime where+  canonicalTypeName _ = T.pack "UTCTime" -instance CanonicalTypeName Day+instance CanonicalTypeName Day where+  canonicalTypeName _ = T.pack "Day" -instance (Typeable a) => CanonicalTypeName (Maybe a)+instance (CanonicalTypeName a) => CanonicalTypeName (Maybe a) where+  canonicalTypeName _ =+    T.concat [T.pack "Maybe(", canonicalTypeName (Proxy @a), T.pack ")"] -instance (Typeable a) => CanonicalTypeName [a]+instance (CanonicalTypeName a) => CanonicalTypeName [a] where+  canonicalTypeName _ =+    T.concat [T.pack "[](", canonicalTypeName (Proxy @a), T.pack ")"] -instance (Typeable a, Typeable b) => CanonicalTypeName (Either a b)+instance (CanonicalTypeName a, CanonicalTypeName b) => CanonicalTypeName (Either a b) where+  canonicalTypeName _ =+    T.concat+      [ T.pack "Either(",+        canonicalTypeName (Proxy @a),+        T.pack ",",+        canonicalTypeName (Proxy @b),+        T.pack ")"+      ] -instance (Typeable a, Typeable b) => CanonicalTypeName (a, b)+instance (CanonicalTypeName a, CanonicalTypeName b) => CanonicalTypeName (a, b) where+  canonicalTypeName _ =+    T.concat+      [ T.pack "(,)(",+        canonicalTypeName (Proxy @a),+        T.pack ",",+        canonicalTypeName (Proxy @b),+        T.pack ")"+      ] -instance (Typeable a, Typeable b, Typeable c) => CanonicalTypeName (a, b, c)+instance+  (CanonicalTypeName a, CanonicalTypeName b, CanonicalTypeName c) =>+  CanonicalTypeName (a, b, c)+  where+  canonicalTypeName _ =+    T.concat+      [ T.pack "(,,)(",+        canonicalTypeName (Proxy @a),+        T.pack ",",+        canonicalTypeName (Proxy @b),+        T.pack ",",+        canonicalTypeName (Proxy @c),+        T.pack ")"+      ]  -- * Shape hash -------------------------------------------------------------- @@ -139,7 +200,7 @@ -- Per EP-36 §3 R3 the hash is a single SHA-256 over the byte -- concatenation of, for each slot in slot-list order, ----- > <slotSymbol> ":" <renderStableTypeRep tr> ";"+-- > <slotSymbol> ":" <canonicalTypeName> ";" -- -- with the empty list anchored at the literal canonical form -- @"regfile:0"@. The recursive structure of the class is therefore
src/Keiki/Symbolic.hs view
@@ -66,6 +66,7 @@     SymGuarded,      -- * Solver-backed analyses+    satResultIsProvablyUnsat,     symIsBot,     symSatExt, @@ -89,6 +90,7 @@  import Control.Monad (when) import Control.Monad.IO.Class (liftIO)+import Data.Fixed (Fixed (MkFixed)) import Data.IORef (IORef, modifyIORef', newIORef, readIORef) import Data.Int (Int32, Int64) import Data.Kind (Type)@@ -98,7 +100,7 @@ import Data.SBV qualified as SBV import Data.Text (Text) import Data.Text qualified as T-import Data.Time (UTCTime)+import Data.Time (UTCTime, nominalDiffTimeToSeconds, secondsToNominalDiffTime) import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds) import Data.Typeable (Typeable) import Data.Word (Word16, Word32, Word64, Word8)@@ -142,72 +144,59 @@   symDefault = 0  -- | Encoded as 'Integer'. SBV does not provide an 'SInt'-of-arbitrary---- size; using 'Integer' avoids overflow surprises during translation.+-- size; using 'Integer' means machine-width 'Int' wraparound is not modeled.+-- Guards whose truth depends on 'Int' overflow should use an explicit+-- fixed-width type instead. instance Sym Int where   type SymRep Int = Integer   toSym = fromIntegral   fromSym = fromIntegral   symDefault = 0 --- The fixed-width integer instances below all encode as the unbounded--- mathematical 'Integer', exactly like 'Sym Int'. This is an--- /over-approximation/: the modular wraparound of the Haskell @Word*@ /--- @Int*@ type is not modeled. The consequence is sound for--- satisfiability (every concrete model the solver finds is a real--- witness once decoded through 'fromSym') but may miss an--- unsatisfiability that depends on overflow (e.g. @x + 1 == 0@ over--- 'Word64' is satisfiable at the type's wrap point but the 'Integer'--- encoding reports it unsat). keiki's money and count guards are--- equality and ordering checks against in-range literals, where the--- over-approximation never bites. The motivating money type is--- @Jitsurei.OrderCart@'s @Money = Word64@ (fixed-point minor units).+-- The fixed-width instances use SBV's matching bit-vector representations, so+-- their arithmetic has exactly the same modular wraparound as Haskell. --- | Money and large counts. Encoded as 'Integer'; see the note above--- on the unbounded-'Integer' over-approximation.+-- | Money and large counts, modeled as an exact 64-bit unsigned value. instance Sym Word64 where-  type SymRep Word64 = Integer-  toSym = fromIntegral-  fromSym = fromIntegral+  type SymRep Word64 = Word64+  toSym = id+  fromSym = id   symDefault = 0 --- | Item counts and similar 32-bit unsigned registers. Encoded as--- 'Integer'; see the over-approximation note above.+-- | Item counts and similar 32-bit unsigned registers, modeled exactly. instance Sym Word32 where-  type SymRep Word32 = Integer-  toSym = fromIntegral-  fromSym = fromIntegral+  type SymRep Word32 = Word32+  toSym = id+  fromSym = id   symDefault = 0 --- | Quantities, basis points, and similar 16-bit unsigned registers.--- Encoded as 'Integer'; see the over-approximation note above.+-- | Quantities, basis points, and similar 16-bit unsigned registers, modeled+-- exactly. instance Sym Word16 where-  type SymRep Word16 = Integer-  toSym = fromIntegral-  fromSym = fromIntegral+  type SymRep Word16 = Word16+  toSym = id+  fromSym = id   symDefault = 0 --- | 8-bit unsigned (completeness). Encoded as 'Integer'; see the--- over-approximation note above.+-- | An exact 8-bit unsigned value. instance Sym Word8 where-  type SymRep Word8 = Integer-  toSym = fromIntegral-  fromSym = fromIntegral+  type SymRep Word8 = Word8+  toSym = id+  fromSym = id   symDefault = 0 --- | 64-bit signed (completeness). Encoded as 'Integer'; see the--- over-approximation note above.+-- | An exact 64-bit signed value. instance Sym Int64 where-  type SymRep Int64 = Integer-  toSym = fromIntegral-  fromSym = fromIntegral+  type SymRep Int64 = Int64+  toSym = id+  fromSym = id   symDefault = 0 --- | 32-bit signed (completeness). Encoded as 'Integer'; see the--- over-approximation note above.+-- | An exact 32-bit signed value. instance Sym Int32 where-  type SymRep Int32 = Integer-  toSym = fromIntegral-  fromSym = fromIntegral+  type SymRep Int32 = Int32+  toSym = id+  fromSym = id   symDefault = 0  -- | 'Text' is encoded as Haskell 'String' for SBV's 'SString' theory.@@ -217,15 +206,18 @@   fromSym = T.pack   symDefault = T.empty --- | 'UTCTime' is encoded as Unix epoch seconds (an 'Integer').--- The round-trip drops sub-second precision; this is intentional —--- the User Registration aggregate's timestamps are at-second--- granularity already, and Integer-encoded time comparisons are well--- supported by SBV's z3 backend.+-- | 'UTCTime' is encoded as picoseconds since the Unix epoch. The time+-- library's 'NominalDiffTime' uses a fixed-point picosecond representation, so+-- this 'Integer' encoding is lossless while remaining well supported by z3. instance Sym UTCTime where   type SymRep UTCTime = Integer-  toSym = round . utcTimeToPOSIXSeconds-  fromSym = posixSecondsToUTCTime . fromIntegral+  toSym t =+    let MkFixed picoseconds =+          nominalDiffTimeToSeconds (utcTimeToPOSIXSeconds t)+     in picoseconds+  fromSym picoseconds =+    posixSecondsToUTCTime+      (secondsToNominalDiffTime (MkFixed picoseconds))   symDefault = posixSecondsToUTCTime 0  -- | Reify a 'Sym' instance so it can be passed around as a@@ -337,11 +329,14 @@ -- be true, and two reads of the same register (or input field) share -- one solver variable. ----- Two pieces of state are shared:+-- Three pieces of state are shared: -- --   * 'seInputCtor' — the symbolic input-constructor tag, so 'PInCtor' --     atoms over distinct constructors are recognized as mutually --     unsatisfiable.+--   * 'seInputArm' — an independent discriminator for 'PLeftArm' and+--     'PRightArm'. It is separate from constructor names so both facts can+--     be asserted by the same guard. --   * 'seVarCache' — a per-translation memo cache (EP-42) keyed by the --     deterministic variable name ('TReg' allocates @"reg/\<slot\>"@, --     'TInpCtorField' allocates @"inp/\<ctor\>/\<field\>"@). The first@@ -359,6 +354,8 @@     --     recognizes that two such constraints with distinct names are     --     mutually unsatisfiable.     seInputCtor :: SBV.SBV String,+    -- | @True@ denotes the outer 'Left' arm; @False@ denotes 'Right'.+    seInputArm :: SBV.SBool,     -- | Memo cache: maps a deterministic variable name ("reg/\<slot\>"     --     or "inp/\<ctor\>/\<field\>") to the single SBV variable allocated     --     for it during this predicate translation. Lazily populated on@@ -385,8 +382,9 @@ mkSymEnv :: SBV.Symbolic SymEnv mkSymEnv = do   ctor <- SBV.free "inputCtor"+  arm <- SBV.free "inputArm"   cache <- liftIO (newIORef Map.empty)-  pure (SymEnv ctor cache)+  pure (SymEnv ctor arm cache)  -- * Translation ------------------------------------------------------------- @@ -494,6 +492,8 @@ --   * 'PInCtor' emits @seInputCtor .== literal (icName ic)@; the --     shared 'seInputCtor' makes constructor-mutual-exclusion --     decidable.+--   * 'PLeftArm' / 'PRightArm' assert the independent 'seInputArm'+--     discriminator. --   * 'PCmp' tries 'discoverSymOrd' on its operand type; on a hit it --     emits the matching SBV comparison ('SBV..<' \/ '.<=' \/ '.>' \/ --     '.>=') between the two translated terms; on a miss it emits a@@ -510,6 +510,8 @@     go (PNot p) = SBV.sNot <$> go p     go (PEq a b) = goEq a b     go (PInCtor ic) = pure (seInputCtor env SBV..== SBV.literal (icName ic))+    go PLeftArm = pure (seInputArm env)+    go PRightArm = pure (SBV.sNot (seInputArm env))     go (PCmp op a b) = goCmp op a b      goEq ::@@ -559,7 +561,10 @@ -- | The v2 'BoolAlg' instance. The five structural methods compose -- 'HsPred' constructors. 'models' delegates to the v1 'evalPred' -- (concrete evaluation, no solver call). 'isBot' routes through--- 'symIsBot', which dispatches to z3 via SBV. Witness extraction+-- 'symIsBot', which dispatches to an external z3 process via SBV and+-- 'unsafePerformIO'. Consequently, evaluating 'isBot' throws an exception from+-- otherwise pure code when z3 is not on @PATH@; the repository's nix development+-- shell supplies it. Witness extraction -- ('Keiki.Core.sat') lives in the separate 'Sat' instance below, which -- carries the 'ExtractRegFile' / 'KnownInCtors' evidence it needs; this -- instance is deliberately /unconstrained/ so the witness-free analyses@@ -587,19 +592,38 @@  -- * Solver-backed analyses -------------------------------------------------- --- | Symbolic emptiness check. Translates the predicate to an SBV--- expression and asks z3 whether any model exists; @True@ when none--- does (the predicate is bot), @False@ otherwise (including the--- conservative 'Unknown' fallback). The 'unsafePerformIO' wrapper is--- justified because every SBV query is deterministic for a given--- predicate and side-effect-free outside the solver process.+-- | Interpret a solver result for emptiness ('Keiki.Core.isBot') purposes.+-- Returns 'True' only for a definite 'SBV.Unsatisfiable' result. Every other+-- result means "not provably empty": that includes 'SBV.Satisfiable',+-- 'SBV.Unknown' (for example, a timeout or an incomplete string-theory query),+-- 'SBV.ProofError', 'SBV.DeltaSat', and 'SBV.SatExtField'. This is the+-- conservative direction for callers that use emptiness to bless two guards as+-- disjoint or to diagnose an edge as dead.+satResultIsProvablyUnsat :: SBV.SatResult -> Bool+satResultIsProvablyUnsat (SBV.SatResult result) = case result of+  SBV.Unsatisfiable {} -> True+  SBV.Satisfiable {} -> False+  SBV.DeltaSat {} -> False+  SBV.SatExtField {} -> False+  SBV.Unknown {} -> False+  SBV.ProofError {} -> False++-- | Symbolic emptiness check. Translates the predicate to an SBV expression and+-- asks z3 whether it is definitely unsatisfiable. A 'True' result proves the+-- predicate is bot; 'False' means either satisfiable or that the solver gave up.+-- The latter can occur for 'Text' guards translated through z3's string theory.+-- This conservative failure direction may surface an overlap warning but never+-- blesses an uncertain pair as disjoint. Requires z3 on @PATH@; because the call+-- is wrapped in 'unsafePerformIO', a missing solver throws from pure code. The+-- wrapper is justified because each query is deterministic for a given predicate+-- and side-effect-free outside the solver process. {-# NOINLINE symIsBot #-} symIsBot :: HsPred rs ci -> Bool symIsBot p = unsafePerformIO $ do   res <- SBV.sat $ do     env <- mkSymEnv     translatePred env p-  pure (not (SBV.modelExists res))+  pure (satResultIsProvablyUnsat res)  -- * Single-valuedness ------------------------------------------------------ @@ -611,7 +635,8 @@ -- 'BoolAlg'-polymorphic; precision depends on the chosen 'isBot' -- implementation. With 'SymPred', this is the v2 SBV-backed -- decision; with the v1 'HsPred' instance the answer is the v1--- syntactic over-approximation.+-- syntactic over-approximation. A solver 'SBV.Unknown' is conservatively treated+-- as a possibly overlapping pair, so this function returns 'False'. isSingleValuedSym ::   forall phi rs s ci co.   (BoolAlg phi (RegFile rs, ci), Bounded s, Enum s) =>@@ -663,7 +688,8 @@ -- 'withSymPred' and runs the 'BoolAlg'-polymorphic 'checkTransitionDeterminism' -- at the 'SymPred' carrier, whose 'isBot' is the exact z3 decision. Unlike the -- pure path in 'validateTransducer', this catches register-value-dependent and--- other non-syntactic overlaps. Requires z3 on @PATH@.+-- other non-syntactic overlaps. A solver 'SBV.Unknown' conservatively produces a+-- warning rather than blessing the pair as disjoint. Requires z3 on @PATH@. checkTransitionDeterminismSym ::   (Bounded s, Enum s, Show s) =>   SymTransducer (HsPred rs ci) rs s ci co ->@@ -676,9 +702,11 @@ -- It does NOT compute the register configurations reachable at each vertex, so -- it still cannot catch the FieldResource case (a guard satisfiable in -- isolation but never under the registers reachable there); that needs a full--- reachable-state fixpoint and is left as future work. Requires z3 on @PATH@.+-- reachable-state fixpoint and is left as future work. A solver 'SBV.Unknown'+-- does not diagnose an edge as dead, because it is not proof of unsatisfiability.+-- Requires z3 on @PATH@. checkDeadEdgesSym ::-  (Bounded s, Enum s, Show s) =>+  (Bounded s, Enum s) =>   SymTransducer (HsPred rs ci) rs s ci co ->   [DeadEdgeWarning s] checkDeadEdgesSym t =@@ -810,7 +838,10 @@ -- outside the solver process). Since EP-44 it /is/ the implementation -- of the 'Keiki.Core.Sat' method 'sat' on 'SymPred' (via the -- @Sat (SymPred …)@ instance, which carries the 'ExtractRegFile' /--- 'KnownInCtors' evidence the witness-free 'BoolAlg' class cannot).+-- 'KnownInCtors' evidence the witness-free 'BoolAlg' class cannot). A 'Nothing'+-- result means only that no model was found: the predicate may be unsatisfiable,+-- or the solver may have returned 'SBV.Unknown'. Callers must not treat+-- 'Nothing' as a proof of emptiness; 'symIsBot' returns 'True' only for that proof. {-# NOINLINE symSatExt #-} symSatExt ::   forall rs ci.
test/Keiki/AcceptorSpec.hs view
@@ -2,7 +2,7 @@  import Data.Time (UTCTime (..), fromGregorian, secondsToDiffTime) import Keiki.Acceptor-import Keiki.Core (initialRegs, isFinal, reconstitute)+import Keiki.Core (InFlight (..), initialRegs, isFinal, reconstitute) import Keiki.Fixtures.EmailDelivery   ( EmailEvent (..),     EmailSentData (..),@@ -10,8 +10,12 @@   ) import Keiki.Fixtures.UserRegistration   ( AccountConfirmedData (..),+    AccountDeletedData (..),     ConfirmAccountData (..),+    ConfirmationEmailSentData (..),+    ConfirmationResentData (..),     FulfillGDPRRequestData (..),+    RegistrationStartedData (..),     StartRegistrationData (..),     UserCmd (..),     UserEvent (..),@@ -52,6 +56,15 @@ canonicalEmailLog =   [EmailSent (EmailSentData "alice@x" "Welcome" (t 0))] +canonicalUserLog :: [UserEvent]+canonicalUserLog =+  [ RegistrationStarted (RegistrationStartedData "alice@x" "Z9F4" (t 0)),+    ConfirmationEmailSent (ConfirmationEmailSentData "alice@x"),+    ConfirmationResent (ConfirmationResentData "alice@x" "K2P7" (t 100)),+    AccountConfirmed (AccountConfirmedData "alice@x" "K2P7" (t 200)),+    AccountDeleted (AccountDeletedData "alice@x" (t 300))+  ]+ spec :: Spec spec = do   describe "inputAcceptor userReg" $ do@@ -81,8 +94,45 @@       accepts (outputAcceptor userReg) badLog `shouldBe` False      it "agrees with reconstitute on the canonical log" $-      fmap fst (runAcceptor (outputAcceptor emailDelivery) canonicalEmailLog)-        `shouldBe` fmap fst (reconstitute emailDelivery canonicalEmailLog)+      accepts (outputAcceptor emailDelivery) canonicalEmailLog+        `shouldBe` maybe+          False+          (isFinal emailDelivery . fst)+          (reconstitute emailDelivery canonicalEmailLog)++  describe "outputAcceptor userReg multi-event replay" $+    do+      it "accepts the canonical multi-event log" $+        accepts (outputAcceptor userReg) canonicalUserLog `shouldBe` True++      it "rejects a truncated chain while preserving its InFlight carrier" $ do+        accepts (outputAcceptor userReg) (take 1 canonicalUserLog)+          `shouldBe` False+        fmap fst (runAcceptor (outputAcceptor userReg) (take 1 canonicalUserLog))+          `shouldBe` Just+            ( InFlight+                RequiresConfirmation+                [ConfirmationEmailSent (ConfirmationEmailSentData "alice@x")]+            )++      it "agrees with reconstitute for complete, truncated, and foreign logs" $ do+        let foreignLog =+              [AccountConfirmed (AccountConfirmedData "alice@x" "Z9F4" (t 0))]+        accepts (outputAcceptor userReg) canonicalUserLog+          `shouldBe` maybe+            False+            (isFinal userReg . fst)+            (reconstitute userReg canonicalUserLog)+        accepts (outputAcceptor userReg) (take 1 canonicalUserLog)+          `shouldBe` maybe+            False+            (isFinal userReg . fst)+            (reconstitute userReg (take 1 canonicalUserLog))+        accepts (outputAcceptor userReg) foreignLog+          `shouldBe` maybe+            False+            (isFinal userReg . fst)+            (reconstitute userReg foreignLog)    describe "aIsFinal" $ do     it "matches isFinal on userReg under fst" $ do
test/Keiki/ArrowSpec.hs view
@@ -18,9 +18,11 @@  import Control.Arrow qualified as Arr import Control.Category qualified as Cat+import Control.Exception (evaluate) import Data.Text (Text) import Data.Text qualified as Text import Keiki.Core+import Keiki.LawHelpers (emittedLog, runScript) import Keiki.Profunctor import Test.Hspec @@ -36,9 +38,6 @@         SomeSymTransducer t ->           omega t (initial t) (initialRegs t) (42 :: Int)             `shouldBe` [("42" :: Text)]-        SomeSymIdentity ->-          expectationFailure-            "Arr.arr unexpectedly returned the identity sentinel"      it "lifts identity-shaped functions but does not detect them as Cat.id" $ do       -- Arr.arr id has the identity *behaviour* on every input but@@ -64,9 +63,6 @@         SomeSymTransducer t ->           omega t (initial t) (initialRegs t) (42 :: Int, "extra" :: Text)             `shouldBe` [("42", "extra")]-        SomeSymIdentity ->-          expectationFailure-            "Arr.first on a non-identity wrapper returned the sentinel"    describe "Cat.id and the Arrow instance interplay" $ do     it "Cat.id passes through arr-style values verbatim" $@@ -80,11 +76,6 @@             SomeSymTransducer t ->               omega t (initial t) (initialRegs t) (99 :: Int)                 `shouldBe` [("99" :: Text)]-            SomeSymIdentity ->-              expectationFailure-                "arr f >>> Cat.id unexpectedly returned the sentinel \-                \— the sentinel short-circuit returns the non-sentinel arg, \-                \so we expect SomeSymTransducer here"      it "Cat.id <<< arr f passes through verbatim too" $       let lifted :: SomeSymTransducer Int Text@@ -93,6 +84,29 @@             SomeSymTransducer t ->               omega t (initial t) (initialRegs t) (5 :: Int)                 `shouldBe` [("5" :: Text)]-            SomeSymIdentity ->-              expectationFailure-                "Cat.id <<< arr f unexpectedly returned the sentinel"++  describe "forward and inversion observations" $ do+    it "standalone arr has a four-command forward trace" $ do+      let lifted = Arr.arr (+ 1) :: SomeSymTransducer Int Int+          script = [1, 2, 3, 4]+      case lifted of+        SomeSymTransducer transducer ->+          runScript transducer script `shouldBe` map (\n -> [n + 1]) script+        SomeSymIdentity -> expectationFailure "arr returned identity sentinel"++    it "arr is not replay-equivalent" $ do+      let lifted = Arr.arr (+ 1) :: SomeSymTransducer Int Int+          script = [1, 2, 3, 4]+      case lifted of+        SomeSymTransducer transducer ->+          case reconstituteEither transducer (emittedLog transducer script) of+            Left _ -> pure ()+            Right _ -> expectationFailure "arr unexpectedly replayed"+        SomeSymIdentity -> expectationFailure "arr returned identity sentinel"++    it "arr fusion fails loudly at the poisoned boundary" $ do+      let fused =+            (Arr.arr (+ 1) Cat.>>> Arr.arr (* 2)) ::+              SomeSymTransducer Int Int+      evaluate fused+        `shouldThrow` (\e -> pceSide e == "upstream output")
test/Keiki/BuilderSpec.hs view
@@ -1,6 +1,9 @@ {-# LANGUAGE BlockArguments #-} {-# LANGUAGE QualifiedDo #-} {-# LANGUAGE TemplateHaskell #-}+-- Constructor derivation emits a complete helper family; each fixture uses+-- only the members relevant to the behavior under test.+{-# OPTIONS_GHC -Wno-unused-top-binds #-}  -- | EP-15 M6: hand-written unit tests for 'Keiki.Builder'. Tests use -- a tiny in-spec toy transducer (single-slot register file, two@@ -9,7 +12,7 @@ module Keiki.BuilderSpec (spec) where  import Control.Exception (evaluate)-import Data.Time (UTCTime (..), fromGregorian, secondsToDiffTime)+import Data.List.NonEmpty qualified as NonEmpty import GHC.Generics (Generic) import Keiki.Builder ((.=), (=:)) import Keiki.Builder qualified as B@@ -68,9 +71,6 @@      ]  ) -t0 :: UTCTime-t0 = UTCTime (fromGregorian 2026 5 2) (secondsToDiffTime 0)- -- * Auxiliary toy: 2-slot register file for case 2 -----------------------  type TwoRegs = '[ '("x", Int), '("y", Int)]@@ -112,7 +112,10 @@ showGuard (POr a b) = "POr (" <> showGuard a <> ") (" <> showGuard b <> ")" showGuard (PNot p) = "PNot (" <> showGuard p <> ")" showGuard (PEq _ _) = "PEq <term> <term>"+showGuard (PCmp _ _ _) = "PCmp <op> <term> <term>" showGuard (PInCtor _) = "PInCtor <ic>"+showGuard PLeftArm = "PLeftArm"+showGuard PRightArm = "PRightArm"  -- * Spec ------------------------------------------------------------------- @@ -218,7 +221,7 @@             B.from A do               B.onCmd inCtorTick $ \_d -> B.do                 B.noEmit -- intentional: no goto-      evaluate (head (edgesOut tr A))+      evaluate tr         `shouldThrow` errorCall           ( "Keiki.Builder: edge #0 from A: goto missing. "               <> "Each onCmd/onEpsilon body must end with "@@ -233,13 +236,39 @@               B.onCmd inCtorTick $ \_d -> B.do                 B.goto B                 B.goto A-      evaluate (head (edgesOut tr A))+      evaluate tr         `shouldThrow` errorCall           ( "Keiki.Builder: edge #0 from A: goto called "               <> "more than once. Each onCmd/onEpsilon body "               <> "must end with exactly one goto V."           ) +    it "bare goto in onCmd (no emit/noEmit) fires the new error" $ do+      let tr = B.buildTransducer A emptyR (const False) do+            B.from A do+              B.onCmd inCtorTick $ \_d -> B.do+                B.goto B+      evaluate tr+        `shouldThrow` errorCall+          ( "Keiki.Builder: edge #0 from A: no emit or noEmit. "+              <> "Each onCmd/onEpsilon body must call 'emit' "+              <> "(or 'emitWith') to produce an event, or 'noEmit' "+              <> "to declare the edge deliberately silent (ε-edge)."+          )++    it "bare goto in onEpsilon (no emit/noEmit) fires the new error" $ do+      let tr = B.buildTransducer A emptyR (const False) do+            B.from A do+              B.onEpsilon B.do+                B.goto B+      evaluate tr+        `shouldThrow` errorCall+          ( "Keiki.Builder: edge #0 from A: no emit or noEmit. "+              <> "Each onCmd/onEpsilon body must call 'emit' "+              <> "(or 'emitWith') to produce an event, or 'noEmit' "+              <> "to declare the edge deliberately silent (ε-edge)."+          )+     -- Case 9: requireEq extends the guard. The starting guard from     -- onCmd is matchInCtor (a PInCtor); requireEq adds a PAnd-PEq     -- conjunct. We assert this by structural inspection.@@ -262,6 +291,7 @@       let tr = B.buildTransducer A emptyR (const False) do             B.from A do               B.onEpsilon B.do+                B.noEmit                 B.goto B       case edgesOut tr A of         [e] -> case guard e of@@ -288,6 +318,82 @@           target e2 `shouldBe` A -- Idle goes to A (second onCmd)         es -> expectationFailure ("expected exactly 2 edges, got " <> show (length es)) +    it "EP-70: missing goto on an undriven vertex fails at construction" $ do+      let tr = B.buildTransducer A emptyR (const False) do+            B.from A do+              B.onCmd inCtorTick $ \_d -> B.do+                B.noEmit+                B.goto A+            B.from B do+              B.onCmd inCtorIdle $ \_d -> B.do+                B.noEmit+      evaluate tr+        `shouldThrow` errorCall+          ( "Keiki.Builder: edge #0 from B: goto missing. "+              <> "Each onCmd/onEpsilon body must end with "+              <> "exactly one goto V."+          )++    it "EP-70: duplicate `from` blocks merge in declaration order" $ do+      let tr = B.buildTransducer A emptyR (const False) do+            B.from A do+              B.onCmd inCtorTick $ \_d -> B.do+                B.noEmit+                B.goto B+            B.from A do+              B.onCmd inCtorIdle $ \_d -> B.do+                B.noEmit+                B.goto A+      case edgesOut tr A of+        [e1, e2] -> do+          target e1 `shouldBe` B+          target e2 `shouldBe` A+        es -> expectationFailure ("expected exactly 2 edges, got " <> show (length es))++    it "EP-70: merged blocks report globally consistent edge indices" $ do+      let tr = B.buildTransducer A emptyR (const False) do+            B.from A do+              B.onCmd inCtorTick $ \_d -> B.do+                B.noEmit+                B.goto B+            B.from A do+              B.onCmd inCtorIdle $ \_d -> B.do+                B.noEmit+      evaluate tr+        `shouldThrow` errorCall+          ( "Keiki.Builder: edge #1 from A: goto missing. "+              <> "Each onCmd/onEpsilon body must end with "+              <> "exactly one goto V."+          )++    it "EP-70: buildTransducerEither returns every defect structurally" $ do+      let malformed = B.buildTransducerEither A emptyR (const False) do+            B.from A do+              B.onCmd inCtorTick $ \_d -> B.do+                B.noEmit+            B.from B do+              B.onCmd inCtorIdle $ \_d -> B.do+                B.goto A+                B.goto B+          expected =+            [ B.BuilderError A 0 B.DefectMissingGoto,+              B.BuilderError B 0 (B.DefectMultipleGoto 2)+            ]+      case malformed of+        Left errors -> NonEmpty.toList errors `shouldBe` expected+        Right _ -> expectationFailure "expected structured builder defects"++      let wellFormed = B.buildTransducerEither A emptyR (const False) do+            B.from A do+              B.onCmd inCtorTick $ \_d -> B.do+                B.noEmit+                B.goto B+      case wellFormed of+        Left errors -> expectationFailure ("unexpected errors: " <> show errors)+        Right tr -> case edgesOut tr A of+          [edge] -> target edge `shouldBe` B+          edges -> expectationFailure ("expected exactly 1 edge, got " <> show (length edges))+   describe "EP-21 M4: field-keyed record sugar for B.emit" $ do     -- Case 12: emit with the per-event record form produces the     -- same omega output as the operator form for the same data.@@ -352,6 +458,21 @@                 B.goto B           cmd = Tick (TickData 5)       omega trEmitWith A emptyR cmd `shouldBe` omega trEmit A emptyR cmd++    it "EP-70: emitWith contradicting onCmd fails eagerly" $ do+      let tr = B.buildTransducer A emptyR (const False) do+            B.from A do+              B.onCmd inCtorTick $ \_d -> B.do+                B.emitWith inCtorIdle wireTicked (OFCons (lit 0) OFNil)+                B.goto B+      evaluate tr+        `shouldThrow` errorCall+          ( "Keiki.Builder: edge #0 from A: emitWith InCtor \"Idle\" "+              <> "(slots []) contradicts the enclosing onCmd's InCtor \"Tick\" "+              <> "(slots [count]). An onCmd edge's outputs must pack the command "+              <> "constructor the edge matches on, or replay will invert the event "+              <> "to a different command."+          )    describe "EP-49: (=:) is a synonym for (.=)" $     -- Authoring the same single-slot edge with `.=` and with `=:`
test/Keiki/BuilderSpike.hs view
@@ -165,6 +165,7 @@    B.from Brewing do     B.onCmd inCtorContinue $ \_d -> B.do+      B.noEmit       B.goto Idle  -- * Misuse demonstrations --------------------------------------------------@@ -241,10 +242,13 @@    describe "EP-15 M2 spike: misuse error messages" $ do     it "missing goto fires at finalize time with the expected message" $-      -- `head` forces the first element of the edges list, which is-      -- the result of `finalizeEdge`. `length` would only walk the-      -- spine and not trigger the error.-      evaluate (head (edgesOut coffeeMissingGoto Idle))+      -- Force the first element, which is the result of `finalizeEdge`.+      -- `length` would only walk the spine and not trigger the error.+      evaluate+        ( case edgesOut coffeeMissingGoto Idle of+            edge : _ -> edge+            [] -> error "coffeeMissingGoto unexpectedly has no edges"+        )         `shouldThrow` errorCall           ( "Keiki.Builder: edge #0 from Idle: goto missing. "               <> "Each onCmd/onEpsilon body must end with "@@ -252,7 +256,11 @@           )      it "duplicated goto fires at finalize time with the expected message" $-      evaluate (head (edgesOut coffeeDoubleGoto Idle))+      evaluate+        ( case edgesOut coffeeDoubleGoto Idle of+            edge : _ -> edge+            [] -> error "coffeeDoubleGoto unexpectedly has no edges"+        )         `shouldThrow` errorCall           ( "Keiki.Builder: edge #0 from Idle: goto called "               <> "more than once. Each onCmd/onEpsilon body must "
+ test/Keiki/BuilderTypeErrorsSpec.hs view
@@ -0,0 +1,77 @@+{-# LANGUAGE BlockArguments #-}+{-# LANGUAGE QualifiedDo #-}+{-# LANGUAGE TemplateHaskell #-}+-- Constructor derivation emits helpers beyond those needed by these+-- compile-time error fixtures.+{-# OPTIONS_GHC -fdefer-type-errors -Wno-deferred-type-errors -Wno-unused-top-binds #-}++module Keiki.BuilderTypeErrorsSpec (spec) where++import Control.Exception (TypeError, evaluate)+import Data.Proxy (Proxy (..))+import GHC.Generics (Generic)+import Keiki.Builder qualified as B+import Keiki.Core (HsPred, OutFields (..), RegFile (..), SymTransducer)+import Keiki.Generics (emptyRegFile)+import Keiki.Generics.TH (deriveAggregateCtors, deriveWireCtors)+import Test.Hspec++type Regs = '[ '("value", Int)]++data Vertex = Start | Done+  deriving (Eq, Show)++data OneData = OneData {value :: Int}+  deriving (Eq, Show, Generic)++data TwoData = TwoData {other :: Int}+  deriving (Eq, Show, Generic)++data Cmd = One OneData | Two TwoData+  deriving (Eq, Show, Generic)++data EventData = EventData {value :: Int}+  deriving (Eq, Show, Generic)++data Event = Emitted EventData+  deriving (Eq, Show, Generic)++$(deriveAggregateCtors ''Cmd ''Regs [("One", "One"), ("Two", "Two")])+$(deriveWireCtors ''Event [("Emitted", "Emitted")])++mismatchedSchemaEmit :: SymTransducer (HsPred Regs Cmd) Regs Vertex Cmd Event+mismatchedSchemaEmit =+  B.buildTransducer Start (emptyRegFile :: RegFile Regs) (const False) do+    B.from Start do+      B.onCmd inCtorOne $ \_d -> B.do+        B.emit wireEmitted (OFCons (inpTwo #other) OFNil)+        B.goto Done++isTypeError :: TypeError -> Bool+isTypeError _ = True++type DupRegs = '[ '("dup", Int), '("dup", Bool)]++_dupRegs :: RegFile DupRegs+_dupRegs = RCons (Proxy @"dup") 0 (RCons (Proxy @"dup") False RNil)++_duplicateSlots :: SymTransducer (HsPred DupRegs ()) DupRegs Vertex () ()+_duplicateSlots =+  B.buildTransducer Start _dupRegs (const False) do+    B.from Start (pure ())++-- Removing -fdefer-type-errors makes '_duplicateSlots' fail compilation with:+--+-- Keiki: register file declares slot "dup" more than once.+-- Slot names in a register file must be pairwise distinct;+-- a duplicated name silently shadows the later slot.+--+-- GHC erases the unsatisfied type-family dictionary under deferred errors,+-- so evaluating the binding cannot catch it as 'TypeError'. Keeping the+-- binding here preserves a compile-only regression alongside the executable+-- mismatched-schema test.++spec :: Spec+spec = do+  it "rejects emit fields projected from a different command schema" $+    evaluate mismatchedSchemaEmit `shouldThrow` isTypeError
test/Keiki/CategorySpec.hs view
@@ -13,12 +13,17 @@ module Keiki.CategorySpec (spec) where  import Control.Category (id, (.))-import Control.Category qualified as Cat import Control.Exception (evaluate)+import Data.Either (isRight)+import Data.Profunctor (lmap, rmap) import Data.Time.Calendar (fromGregorian) import Data.Time.Clock (UTCTime (..), secondsToDiffTime)+import Keiki.Composition import Keiki.Core+import Keiki.Fixtures.CounterPipeline import Keiki.Fixtures.EmailDelivery+import Keiki.Generics (Append)+import Keiki.LawHelpers (emittedLog) import Keiki.Profunctor import Keiki.Symbolic (isSingleValuedSym, withSymPred) import Test.Hspec@@ -89,6 +94,29 @@   omega t (initial t) (initialRegs t) ci runOmega SomeSymIdentity ci = [ci] +-- | Fold 'step' over an input sequence from the initial state,+-- collecting each step's emissions. 'Nothing' means a step rejected.+runSteps :: SomeSymTransducer ci co -> [ci] -> Maybe [[co]]+runSteps (SomeSymTransducer t) inputs = go (initial t, initialRegs t) inputs+  where+    go _ [] = Just []+    go st (ci : rest) = case step t st ci of+      Nothing -> Nothing+      Just (s', regs', cos_) -> (cos_ :) <$> go (s', regs') rest+runSteps SomeSymIdentity inputs = Just (map (: []) inputs)++replays :: (Eq co) => SomeSymTransducer ci co -> [ci] -> Bool+replays (SomeSymTransducer transducer) inputs =+  isRight (reconstituteEither transducer (emittedLog transducer inputs))+replays SomeSymIdentity _ = True++-- | The slot names the wrapper's hidden register file reports.+wrapperSlotNames :: SomeSymTransducer ci co -> [String]+wrapperSlotNames someT = case someT of+  SomeSymTransducer (_ :: SymTransducer (HsPred rs ci) rs s ci co) ->+    slotNames @rs+  SomeSymIdentity -> []+ -- * Specs -------------------------------------------------------------------  spec :: Spec@@ -115,15 +143,25 @@       runOmega (someEmail . id) sampleSendEmail         `shouldBe` runOmega someEmail sampleSendEmail -    it "L3 associativity: (t3 . t2) . t1 == t3 . (t2 . t1) on a representative input" $-      let t1 = someEmail-          t2 = id :: SomeSymTransducer EmailEvent EmailEvent-          t3 = id :: SomeSymTransducer EmailEvent EmailEvent-          left = (t3 . t2) . t1-          right = t3 . (t2 . t1)-       in runOmega left sampleSendEmail-            `shouldBe` runOmega right sampleSendEmail+    it "L3 associativity: three stateful stages agree under both associations" $ do+      let wa = someSymTransducer stageA+          wb = someSymTransducer stageB+          wc = someSymTransducer stageC+          inputs = [MsgA 1, MsgA 5, MsgA 2, MsgA 3]+          expected = Just [[MsgD 3], [MsgD 14], [MsgD 19], [MsgD 26]]+          left = runSteps ((wc . wb) . wa) inputs+          right = runSteps (wc . (wb . wa)) inputs+      left `shouldBe` right+      left `shouldBe` expected+      right `shouldBe` expected +      -- stageA's derived-only MsgB output cannot reconstruct MsgA, so neither+      -- association repairs the fixture's pre-existing replay defect. Keep+      -- this as an explicit inversion counterexample alongside the forward+      -- associativity observation above.+      replays ((wc . wb) . wa) inputs `shouldBe` False+      replays (wc . (wb . wa)) inputs `shouldBe` False+     it "L1 with concrete output: id . someEmail still emits the wire EmailEvent" $       runOmega (id . someEmail) sampleSendEmail         `shouldBe` [sampleEmailEvent]@@ -151,20 +189,86 @@       runOmega composedL sampleSendEmail `shouldBe` [sampleEmailEvent]       runOmega composedR sampleSendEmail `shouldBe` [sampleEmailEvent] +  describe "PoisonedCompositionError on mapped boundaries" $ do+    it "rejects an upstream rmap boundary" $ do+      let wa = someSymTransducer stageA+          wb = someSymTransducer stageB+          mapped = rmap (\(MsgB n) -> MsgB (n + 100)) wa+      evaluate (wb . mapped)+        `shouldThrow` (\e -> pceSide e == "upstream output")++    it "rejects a downstream lmap boundary" $ do+      let wa = someSymTransducer stageA+          wb = someSymTransducer stageB+          mapped = lmap (\(MsgB n) -> MsgB (n + 100)) wb+      evaluate (mapped . wa)+        `shouldThrow` (\e -> pceSide e == "downstream input")++    it "allows an output map outside composition and applies it" $ do+      let wa = someSymTransducer stageA+          wb = someSymTransducer stageB+          mapped = rmap (\(MsgC n) -> MsgC (n + 100)) (wb . wa)+      runOmega mapped (MsgA 2) `shouldBe` [MsgC 105]++    it "carries an outer output poison into the next boundary" $ do+      let wa = someSymTransducer stageA+          wb = someSymTransducer stageB+          wc = someSymTransducer stageC+          mapped = rmap (\(MsgC n) -> MsgC (n + 100)) (wb . wa)+      evaluate (wc . mapped)+        `shouldThrow` (\e -> pceSide e == "upstream output")++    it "stamped concrete composition is dead instead of silently bypassing rmap" $ do+      let concrete = compose (rmapCo id stageA) stageB+      case step concrete (initial concrete, initialRegs concrete) (MsgA 2) of+        Nothing -> pure ()+        Just _ -> expectationFailure "stamped boundary unexpectedly fired"++  describe "nested stateful composition regressions" $ do+    it "touches the final stage's slots after a nested upstream composite" $ do+      let wa = someSymTransducer stageA+          wb = someSymTransducer stageB+          wc = someSymTransducer stageC+      runSteps (wc . (wb . wa)) [MsgA 1, MsgA 5, MsgA 2]+        `shouldBe` Just [[MsgD 3], [MsgD 14], [MsgD 19]]++    it "reports the real concatenated slot names" $ do+      let wa = someSymTransducer stageA+          wb = someSymTransducer stageB+          wc = someSymTransducer stageC+      wrapperSlotNames (wc . (wb . wa))+        `shouldBe` ["regA", "regB", "regC"]++    it "detects slot overlap against a nested composite" $ do+      let wa = someSymTransducer stageA+          wb = someSymTransducer stageB+          wc = someSymTransducer stageC+          conflict = someSymTransducer stageConflict+          composed = conflict . (wc . (wb . wa))+      evaluate composed+        `shouldThrow` (\e -> "regA" `elem` coeSlots e)++  describe "slot-list witness toolkit" $ do+    it "reports names for a concrete witness" $+      witnessNames (slotWitness @ARegs) `shouldBe` ["regA"]++    it "appends concrete witnesses in register order" $+      witnessNames (appendWitness (slotWitness @ARegs) (slotWitness @BRegs))+        `shouldBe` ["regA", "regB"]++    it "derives KnownSlots for an appended witness" $+      withKnownSlots+        (appendWitness (slotWitness @ARegs) (slotWitness @BRegs))+        (slotNames @(Append ARegs BRegs))+        `shouldBe` ["regA", "regB"]+   describe "isSingleValuedSym survives id . t" $ do     it "single-valuedness is preserved across left identity" $       case id . someEmail of         SomeSymTransducer t ->           isSingleValuedSym (withSymPred t) `shouldBe` True-        SomeSymIdentity ->-          expectationFailure-            "id . someEmail unexpectedly short-circuited to SomeSymIdentity \-            \— someEmail is not the identity sentinel"      it "single-valuedness is preserved across right identity" $       case someEmail . id of         SomeSymTransducer t ->           isSingleValuedSym (withSymPred t) `shouldBe` True-        SomeSymIdentity ->-          expectationFailure-            "someEmail . id unexpectedly short-circuited to SomeSymIdentity"
test/Keiki/ChoiceSpec.hs view
@@ -27,7 +27,10 @@ import Data.Time.Calendar (fromGregorian) import Data.Time.Clock (UTCTime (..), secondsToDiffTime) import Keiki.Core+import Keiki.Fixtures.ComposeStateful+import Keiki.Fixtures.CounterPipeline import Keiki.Fixtures.EmailDelivery+import Keiki.LawHelpers (emittedLog, runScript) import Keiki.Profunctor import Keiki.Symbolic (isSingleValuedSym, withSymPred) import Test.Hspec@@ -58,6 +61,17 @@ someEmail :: SomeSymTransducer EmailCmd EmailEvent someEmail = someSymTransducer emailDelivery +-- | Fold 'step' over inputs, retaining state between steps and+-- collecting each step's emissions.+runSteps :: SomeSymTransducer ci co -> [ci] -> Maybe [[co]]+runSteps (SomeSymTransducer t) inputs = go (initial t, initialRegs t) inputs+  where+    go _ [] = Just []+    go st (ci : rest) = case step t st ci of+      Nothing -> Nothing+      Just (s', regs', cos_) -> (cos_ :) <$> go (s', regs') rest+runSteps SomeSymIdentity inputs = Just (map (: []) inputs)+ -- * Specs -------------------------------------------------------------------  spec :: Spec@@ -70,10 +84,6 @@         SomeSymTransducer t ->           omega t (initial t) (initialRegs t) (Left sampleSendEmail)             `shouldBe` [(Left sampleEmailEvent)]-        SomeSymIdentity ->-          expectationFailure-            "left' (someSymTransducer emailDelivery) unexpectedly returned \-            \the identity sentinel"      it "Right input passes through unchanged on the identity arm" $ do       let routedLeft :: SomeSymTransducer (Either EmailCmd Int) (Either EmailEvent Int)@@ -82,10 +92,6 @@         SomeSymTransducer t ->           omega t (initial t) (initialRegs t) (Right (42 :: Int))             `shouldBe` [(Right 42)]-        SomeSymIdentity ->-          expectationFailure-            "left' (someSymTransducer emailDelivery) unexpectedly returned \-            \the identity sentinel"      it "preserves Cat.id on the sentinel: left' Cat.id == Cat.id" $ do       let lifted :: SomeSymTransducer (Either Int Bool) (Either Int Bool)@@ -95,6 +101,33 @@         SomeSymTransducer _ ->           expectationFailure "left' Cat.id should preserve the identity sentinel" +    it "composes statefully with another left' result" $ do+      let bL =+            left' (someSymTransducer stageB) ::+              SomeSymTransducer (Either MsgB Bool) (Either MsgC Bool)+          cL =+            left' (someSymTransducer stageC) ::+              SomeSymTransducer (Either MsgC Bool) (Either MsgD Bool)+      runSteps (cL Cat.. bL) [Left (MsgB 1), Right True, Left (MsgB 2)]+        `shouldBe` Just [[Left (MsgD 2)], [Right True], [Left (MsgD 5)]]++    it "evolves independently over an interleaved four-command script" $ do+      let lifted =+            left' (someSymTransducer counterSource) ::+              SomeSymTransducer (Either SourceCmd Bool) (Either MidVal Bool)+          script = [Left Tick, Right True, Left Tick, Right False]+      case lifted of+        SomeSymTransducer transducer -> do+          runScript transducer script+            `shouldBe` [ [Left (MidVal 0)],+                         [Right True],+                         [Left (MidVal 1)],+                         [Right False]+                       ]+          case reconstituteEither transducer (emittedLog transducer script) of+            Right _ -> pure ()+            Left _ -> expectationFailure "Choice replay failed"+   describe "right'" $ do     it "Right input routes through the wrapped transducer" $ do       let routedRight :: SomeSymTransducer (Either Int EmailCmd) (Either Int EmailEvent)@@ -103,10 +136,6 @@         SomeSymTransducer t ->           omega t (initial t) (initialRegs t) (Right sampleSendEmail)             `shouldBe` [(Right sampleEmailEvent)]-        SomeSymIdentity ->-          expectationFailure-            "right' (someSymTransducer emailDelivery) unexpectedly returned \-            \the identity sentinel"      it "Left input passes through unchanged on the identity arm" $ do       let routedRight :: SomeSymTransducer (Either Int EmailCmd) (Either Int EmailEvent)@@ -115,10 +144,6 @@         SomeSymTransducer t ->           omega t (initial t) (initialRegs t) (Left (7 :: Int))             `shouldBe` [(Left 7)]-        SomeSymIdentity ->-          expectationFailure-            "right' (someSymTransducer emailDelivery) unexpectedly returned \-            \the identity sentinel"      it "preserves Cat.id on the sentinel: right' Cat.id == Cat.id" $ do       let lifted :: SomeSymTransducer (Either Bool Int) (Either Bool Int)@@ -128,21 +153,25 @@         SomeSymTransducer _ ->           expectationFailure "right' Cat.id should preserve the identity sentinel" +    it "composes statefully with another right' result" $ do+      let bR =+            right' (someSymTransducer stageB) ::+              SomeSymTransducer (Either Bool MsgB) (Either Bool MsgC)+          cR =+            right' (someSymTransducer stageC) ::+              SomeSymTransducer (Either Bool MsgC) (Either Bool MsgD)+      runSteps (cR Cat.. bR) [Right (MsgB 1), Left False, Right (MsgB 2)]+        `shouldBe` Just [[Right (MsgD 2)], [Left False], [Right (MsgD 5)]]+   describe "isSingleValuedSym survives left' / right'" $ do     it "single-valuedness is preserved across left'" $       case left' someEmail ::              SomeSymTransducer (Either EmailCmd Int) (Either EmailEvent Int) of         SomeSymTransducer t ->           isSingleValuedSym (withSymPred t) `shouldBe` True-        SomeSymIdentity ->-          expectationFailure-            "left' on a non-identity wrapper returned the identity sentinel"      it "single-valuedness is preserved across right'" $       case right' someEmail ::              SomeSymTransducer (Either Int EmailCmd) (Either Int EmailEvent) of         SomeSymTransducer t ->           isSingleValuedSym (withSymPred t) `shouldBe` True-        SomeSymIdentity ->-          expectationFailure-            "right' on a non-identity wrapper returned the identity sentinel"
+ test/Keiki/CompositionAlignmentSpec.hs view
@@ -0,0 +1,118 @@+module Keiki.CompositionAlignmentSpec (spec) where++import Data.Proxy (Proxy (..))+import Keiki.Composition+import Keiki.Core+import Keiki.Fixtures.ComposeStateful+import Keiki.Fixtures.CounterPipeline+import Keiki.Profunctor (rmapCo)+import Test.Hspec++type Payload1 = '[ '("payload", Int)]++type Payload2 = '[ '("first", Int), '("second", Int)]++typoInMsgB :: InCtor MsgB Payload1+typoInMsgB =+  InCtor+    { icName = "MsgTypo",+      icMatch = \(MsgB n) -> Just (RCons (Proxy @"payload") n RNil),+      icBuild = \(RCons _ n RNil) -> MsgB n+    }++twoFieldInMsgB :: InCtor MsgB Payload2+twoFieldInMsgB =+  InCtor+    { icName = "MsgB",+      icMatch = \(MsgB n) ->+        Just+          ( RCons+              (Proxy @"first")+              n+              (RCons (Proxy @"second") n RNil)+          ),+      icBuild = \(RCons _ n (RCons _ _ RNil)) -> MsgB n+    }++misnamedStageB :: SymTransducer (HsPred BRegs MsgB) BRegs StageVertex MsgB MsgC+misnamedStageB =+  SymTransducer+    { edgesOut = \StageVertex ->+        [ Edge+            { guard = PInCtor typoInMsgB,+              update = UKeep,+              output = [],+              target = StageVertex+            }+        ],+      initial = StageVertex,+      initialRegs = RCons (Proxy @"regB") 0 RNil,+      isFinal = const True+    }++arityStageB :: SymTransducer (HsPred BRegs MsgB) BRegs StageVertex MsgB MsgC+arityStageB =+  SymTransducer+    { edgesOut = \StageVertex ->+        [ Edge+            { guard =+                PAnd+                  (PInCtor twoFieldInMsgB)+                  ( PEq+                      (TInpCtorField twoFieldInMsgB (SIdx ZIdx))+                      (TLit (0 :: Int))+                  ),+              update = UKeep,+              output = [],+              target = StageVertex+            }+        ],+      initial = StageVertex,+      initialRegs = RCons (Proxy @"regB") 0 RNil,+      isFinal = const True+    }++spec :: Spec+spec = do+  describe "checkComposeAlignment" $ do+    it "accepts aligned fixture pairs and composeChecked builds them" $ do+      checkComposeAlignment stageA stageB `shouldBe` []+      checkComposeAlignment stageB stageC `shouldBe` []+      checkComposeAlignment counterSource lastValueSink `shouldBe` []+      checkComposeAlignment pairSource twoPhaseSink `shouldBe` []+      case composeChecked stageA stageB of+        Right _ -> pure ()+        Left warnings -> expectationFailure ("aligned pair warned: " <> show warnings)++    it "reports both sides of a constructor-name drift with exact edges" $ do+      checkComposeAlignment stageA misnamedStageB+        `shouldBe` [ UnconsumedWireOutput+                       (EdgeRef StageVertex 0)+                       "MsgB"+                       StageVertex,+                     UnmatchedInCtorExpectation+                       (EdgeRef StageVertex 0)+                       "MsgTypo"+                       StageVertex+                   ]+      case composeChecked stageA misnamedStageB of+        Left _ -> pure ()+        Right _ -> expectationFailure "misnamed pair passed composeChecked"++    it "reports an out-of-range field read before evaluation" $+      checkComposeAlignment stageA arityStageB+        `shouldContain` [ FieldArityMismatch+                            (EdgeRef StageVertex 0)+                            (EdgeRef StageVertex 0)+                            "MsgB"+                            1+                            1+                        ]++    it "flags stamped mapped names explicitly" $ do+      let warnings = checkComposeAlignment (rmapCo id stageA) stageB+      warnings+        `shouldSatisfy` any (\case PoisonedNameInComposition "MsgB#rmapped" "upstream output" -> True; _ -> False)++    it "walks every symbol in a multi-event source chain" $+      checkComposeAlignment pairSource twoPhaseSink `shouldBe` []
test/Keiki/CompositionAlternativeSpec.hs view
@@ -1,3 +1,5 @@+{-# LANGUAGE BlockArguments #-}+{-# LANGUAGE QualifiedDo #-} {-# LANGUAGE TemplateHaskell #-}  -- | Acceptance tests for 'Keiki.Composition.alternative' under EP-25@@ -33,6 +35,7 @@ import Data.Time.Calendar (fromGregorian) import Data.Time.Clock (UTCTime (..), secondsToDiffTime) import GHC.Generics (Generic)+import Keiki.Builder qualified as B import Keiki.Composition (Composite (..), alternative) import Keiki.Core import Keiki.Fixtures.EmailDelivery@@ -149,6 +152,23 @@     (Either EmailEvent PingEvent) siblings = alternative emailDelivery pinger +epsilonRight :: SymTransducer (HsPred '[] PingCmd) '[] Bool PingCmd PingEvent+epsilonRight =+  B.buildTransducer False RNil id do+    B.from False do+      B.onEpsilon B.do+        B.noEmit+        B.goto True++epsilonSiblings ::+  SymTransducer+    (HsPred EmailRegs (Either EmailCmd PingCmd))+    EmailRegs+    (Composite EmailVertex Bool)+    (Either EmailCmd PingCmd)+    (Either EmailEvent PingEvent)+epsilonSiblings = alternative emailDelivery epsilonRight+ -- * Test fixtures --------------------------------------------------------  sampleAt :: UTCTime@@ -186,6 +206,19 @@ spec = do   describe "alternative emailDelivery pinger" $ do     describe "step routing" $ do+      it "arm-restricts an onEpsilon-authored right edge on Left input" $+        case step+          epsilonSiblings+          (initial epsilonSiblings, initialRegs epsilonSiblings)+          (Left sampleSendEmail) of+          Just (Composite ev rightVertex, _, [Left co]) -> do+            ev `shouldBe` EmailSentVertex+            rightVertex `shouldBe` False+            co `shouldBe` sampleEmailEvent+          other ->+            expectationFailure+              ("expected only the Left edge to fire, got " <> showStep other)+       it "Left input advances the EmailDelivery arm and emits Left output" $         case step           siblings@@ -242,6 +275,9 @@       it "the alternative composite is single-valued" $         isSingleValuedSym (withSymPred siblings) `shouldBe` True +      it "keeps an alternative with a PTop right edge single-valued" $+        isSingleValuedSym (withSymPred epsilonSiblings) `shouldBe` True+     describe "reconstitute (mixed-arm event log replay)" $ do       it "lands at Composite EmailSentVertex PingDone on a Left+Right log" $         case reconstitute siblings [Left sampleEmailEvent, Right samplePingEvent] of@@ -279,8 +315,9 @@           `shouldBe` [(Right samplePingEvent)]   where     showStep ::+      (Show pv) =>       Maybe-        ( Composite EmailVertex PingVertex,+        ( Composite EmailVertex pv,           x,           [Either EmailEvent PingEvent]         ) ->
test/Keiki/CompositionFeedback1Spec.hs view
@@ -242,6 +242,17 @@ spec = do   describe "feedback1 toggleAgg togglePolicy" $ do     describe "single-step cascade" $ do+      it "documents two independent aggregate copies, not shared-state feedback" $+        case step loop (initial loop, initialRegs loop) externalCmd of+          Just (Composite outerT (Composite _ innerT), _, _) -> do+            outerT `shouldBe` On+            innerT `shouldBe` On+            -- A shared toggle would process the policy command from the+            -- outer copy's new On state and finish Off. The inner On proves+            -- that feedback1 instead starts its second copy from Off.+            innerT `shouldNotBe` Off+          Nothing -> expectationFailure "two-copy cascade rejected its command"+       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
+ test/Keiki/CompositionHomomorphismSpec.hs view
@@ -0,0 +1,154 @@+module Keiki.CompositionHomomorphismSpec (spec) where++import Control.Exception (evaluate)+import Control.Monad (foldM, forM_)+import Data.Text (Text)+import Data.Time (UTCTime)+import Keiki.Composition (Composite (..), compose)+import Keiki.CompositionSpec (AlertCmd, AlertRegs, alertSource, sampleTrigger)+import Keiki.Core+import Keiki.Fixtures.ComposeStateful+import Keiki.Fixtures.EmailDelivery (EmailRegs, emailDelivery)+import Keiki.Generics (Append)+import Test.Hspec++sequentialStep ::+  SymTransducer (HsPred rs1 ci) rs1 s1 ci mid ->+  SymTransducer (HsPred rs2 mid) rs2 s2 mid co ->+  (s1, RegFile rs1) ->+  (s2, RegFile rs2) ->+  ci ->+  Maybe ((s1, RegFile rs1), (s2, RegFile rs2), [co])+sequentialStep t1 t2 state1 state2 command = do+  (s1', regs1', mids) <- step t1 state1 command+  let feed (s2, regs2, outputs) mid = do+        (s2', regs2', emitted) <- step t2 (s2, regs2) mid+        pure (s2', regs2', outputs <> emitted)+  (s2', regs2', outputs) <- foldM feed (fst state2, snd state2, []) mids+  pure ((s1', regs1'), (s2', regs2'), outputs)++spec :: Spec+spec = do+  describe "bounded-exhaustive compose homomorphism" $ do+    it "agrees for counterSource then lastValueSink through three steps" $+      forM_ [replicate n Tick | n <- [0 .. 3]] checkCounterSequence++    it "agrees for pairSource then twoPhaseSink, including later rejection" $+      forM_ [[], [Go], [Go, Go]] checkPhaseSequence++    it "records the wrong-order guard as a deliberate refinement of sequential bottom" $ do+      evaluate+        ( step+            wrongOrderSink+            (initial wrongOrderSink, initialRegs wrongOrderSink)+            (M2A 5)+        )+        `shouldThrow` errorCall "evalTerm: TInpCtorField guard violation: M2B"+      let pipeline = compose m2aSource wrongOrderSink+      case step pipeline (initial pipeline, initialRegs pipeline) ProduceA of+        Just (_, _, outputs) -> outputs `shouldBe` [SawA 5]+        Nothing -> expectationFailure "expected composition to refine the guard error to a defined step"++    it "agrees for alertSource then emailDelivery, including terminal rejection" $+      forM_ [[], [sampleTrigger], [sampleTrigger, sampleTrigger]] checkAlertSequence++checkCounterSequence :: [SourceCmd] -> Expectation+checkCounterSequence =+  go+    (initial pipeline, initialRegs pipeline)+    (initial counterSource, initialRegs counterSource)+    (initial lastValueSink, initialRegs lastValueSink)+  where+    pipeline = compose counterSource lastValueSink++    go _ _ _ [] = pure ()+    go compositeState sourceState sinkState (command : rest) =+      case ( step pipeline compositeState command,+             sequentialStep counterSource lastValueSink sourceState sinkState command+           ) of+        (Nothing, Nothing) -> pure ()+        (Just (compositeVertex, compositeRegs, compositeOutputs), Just ((sourceVertex, sourceRegs), (sinkVertex, sinkRegs), sequentialOutputs)) -> do+          compositeVertex `shouldBe` Composite sourceVertex sinkVertex+          compositeOutputs `shouldBe` sequentialOutputs+          readSourceCount compositeRegs+            `shouldBe` sourceRegs ! (#srcCount :: Index CounterRegs Int)+          readSinkLast compositeRegs+            `shouldBe` sinkRegs ! (#sinkLast :: Index SinkRegs Int)+          go+            (compositeVertex, compositeRegs)+            (sourceVertex, sourceRegs)+            (sinkVertex, sinkRegs)+            rest+        _ -> expectationFailure "counter pipeline disagreed on accept/reject"++checkPhaseSequence :: [PairCmd] -> Expectation+checkPhaseSequence =+  go+    (initial pipeline, initialRegs pipeline)+    (initial pairSource, initialRegs pairSource)+    (initial twoPhaseSink, initialRegs twoPhaseSink)+  where+    pipeline = compose pairSource twoPhaseSink++    go _ _ _ [] = pure ()+    go compositeState sourceState sinkState (command : rest) =+      case ( step pipeline compositeState command,+             sequentialStep pairSource twoPhaseSink sourceState sinkState command+           ) of+        (Nothing, Nothing) -> pure ()+        (Just (compositeVertex, compositeRegs, compositeOutputs), Just ((sourceVertex, sourceRegs), (sinkVertex, sinkRegs), sequentialOutputs)) -> do+          compositeVertex `shouldBe` Composite sourceVertex sinkVertex+          compositeOutputs `shouldBe` sequentialOutputs+          readPhase compositeRegs+            `shouldBe` sinkRegs ! (#phase :: Index PhaseRegs Int)+          go+            (compositeVertex, compositeRegs)+            (sourceVertex, sourceRegs)+            (sinkVertex, sinkRegs)+            rest+        _ -> expectationFailure "two-phase pipeline disagreed on accept/reject"++checkAlertSequence :: [AlertCmd] -> Expectation+checkAlertSequence =+  go+    (initial pipeline, initialRegs pipeline)+    (initial alertSource, initialRegs alertSource)+    (initial emailDelivery, initialRegs emailDelivery)+  where+    pipeline = compose alertSource emailDelivery++    go _ _ _ [] = pure ()+    go compositeState sourceState sinkState (command : rest) =+      case ( step pipeline compositeState command,+             sequentialStep alertSource emailDelivery sourceState sinkState command+           ) of+        (Nothing, Nothing) -> pure ()+        (Just (compositeVertex, compositeRegs, compositeOutputs), Just ((sourceVertex, sourceRegs), (sinkVertex, sinkRegs), sequentialOutputs)) -> do+          compositeVertex `shouldBe` Composite sourceVertex sinkVertex+          compositeOutputs `shouldBe` sequentialOutputs+          compareAlertRegisters compositeRegs sourceRegs sinkRegs+          go+            (compositeVertex, compositeRegs)+            (sourceVertex, sourceRegs)+            (sinkVertex, sinkRegs)+            rest+        _ -> expectationFailure "alert/email pipeline disagreed on accept/reject"++compareAlertRegisters ::+  RegFile (Append AlertRegs EmailRegs) ->+  RegFile AlertRegs ->+  RegFile EmailRegs ->+  Expectation+compareAlertRegisters compositeRegs sourceRegs sinkRegs = do+  compositeRegs ! (#alertRecipient :: Index (Append AlertRegs EmailRegs) Text)+    `shouldBe` sourceRegs ! (#alertRecipient :: Index AlertRegs Text)+  compositeRegs ! (#alertSubject :: Index (Append AlertRegs EmailRegs) Text)+    `shouldBe` sourceRegs ! (#alertSubject :: Index AlertRegs Text)+  compositeRegs ! (#alertAt :: Index (Append AlertRegs EmailRegs) UTCTime)+    `shouldBe` sourceRegs ! (#alertAt :: Index AlertRegs UTCTime)+  compositeRegs ! (#emailRecipient :: Index (Append AlertRegs EmailRegs) Text)+    `shouldBe` sinkRegs ! (#emailRecipient :: Index EmailRegs Text)+  compositeRegs ! (#emailSubject :: Index (Append AlertRegs EmailRegs) Text)+    `shouldBe` sinkRegs ! (#emailSubject :: Index EmailRegs Text)+  compositeRegs ! (#emailSentAt :: Index (Append AlertRegs EmailRegs) UTCTime)+    `shouldBe` sinkRegs ! (#emailSentAt :: Index EmailRegs UTCTime)
test/Keiki/CompositionNarySpec.hs view
@@ -1,4 +1,7 @@ {-# LANGUAGE TemplateHaskell #-}+-- Constructor derivation emits complete command/event helper families; this+-- spec deliberately exercises only the helpers needed for composition.+{-# OPTIONS_GHC -Wno-unused-top-binds #-}  -- | EP-48: N-ary event-family codec composition and singleton events. --
test/Keiki/CompositionSpec.hs view
@@ -25,7 +25,10 @@     -- @docs/plans/31-mermaid-rendering-for-composite-symtransducers.md@     -- for why we re-export rather than duplicate the fixture.     alertSource,+    AlertCmd (..),+    AlertRegs,     AlertVertex (..),+    sampleTrigger,   ) where @@ -57,10 +60,6 @@ -- | Command sum for the source aggregate. data AlertCmd = TriggerAlert TriggerAlertData   deriving stock (Eq, Show, Generic)---- | The source aggregate's *output* type is EmailCmd — so the--- composite's mid alphabet aligns with EmailDelivery's input.-type AlertEvent = EmailCmd  -- | Register file for the source aggregate. Mirrors EmailRegs in -- field shape but with distinct slot names so 'Append AlertRegs
+ test/Keiki/CompositionStatefulSpec.hs view
@@ -0,0 +1,48 @@+module Keiki.CompositionStatefulSpec (spec) where++import Control.Exception (evaluate)+import Data.Text qualified as Text+import Keiki.Composition (compose)+import Keiki.Core+import Keiki.Fixtures.ComposeStateful+import Keiki.Render.Pretty (prettyPred)+import Keiki.Symbolic (isSingleValuedSym, withSymPred)+import Test.Hspec++spec :: Spec+spec = do+  describe "compose counterSource lastValueSink" $+    it "stores the pre-increment count in the sink" $ do+      let pipeline = compose counterSource lastValueSink+      case step pipeline (initial pipeline, initialRegs pipeline) Tick of+        Just (_, regs, outputs) -> do+          readSourceCount regs `shouldBe` 1+          readSinkLast regs `shouldBe` 0+          outputs `shouldBe` [OutVal 0]+        Nothing -> expectationFailure "expected the composed counter step to succeed"++  describe "compose pairSource twoPhaseSink" $+    it "fires phase 1 then phase 2 and ends at phase 2" $ do+      let pipeline = compose pairSource twoPhaseSink+      case step pipeline (initial pipeline, initialRegs pipeline) Go of+        Just (_, regs, outputs) -> do+          readPhase regs `shouldBe` 2+          outputs `shouldBe` [Stage1 10, Stage2 20]+        Nothing -> expectationFailure "expected the composed two-phase step to succeed"++  describe "compose m2aSource wrongOrderSink" $ do+    it "steps via the M2A edge without raising" $ do+      let pipeline = compose m2aSource wrongOrderSink+      result <- evaluate (step pipeline (initial pipeline, initialRegs pipeline) ProduceA)+      case result of+        Just (_, _, outputs) -> outputs `shouldBe` [SawA 5]+        Nothing -> expectationFailure "expected the matching M2A edge to fire"++    it "is safe for symbolic, pretty-printing, and replay walkers" $ do+      let pipeline = compose m2aSource wrongOrderSink+          renderedGuards = map (prettyPred . guard) (edgesOut pipeline (initial pipeline))+      isSingleValuedSym (withSymPred pipeline) `shouldBe` True+      sum (map Text.length renderedGuards) `shouldSatisfy` (> 0)+      case reconstitute pipeline [SawA 5] of+        Just _ -> pure ()+        Nothing -> expectationFailure "expected the valid SawA event to replay"
test/Keiki/CoreHiddenInputsGSMSpec.hs view
@@ -1,184 +1,51 @@ module Keiki.CoreHiddenInputsGSMSpec (spec) where  import Data.List (isInfixOf)-import Data.Proxy (Proxy (..)) import Keiki.Core+import Keiki.Fixtures.SplitCoverage import Test.Hspec --- | A 3-slot input constructor used to stress the union check.-data MultiInput = Begin Int Int Int deriving (Eq, Show)--data MultiOutput-  = OutAB Int Int -- recovers slots a, b-  | OutBC Int Int -- recovers slots b, c-  | OutA Int -- recovers slot a only-  deriving (Eq, Show)--inCtorBegin :: InCtor MultiInput '[ '("a", Int), '("b", Int), '("c", Int)]-inCtorBegin =-  InCtor-    { icName = "Begin",-      icMatch = \case-        Begin a b c ->-          Just $-            RCons (Proxy @"a") a $-              RCons (Proxy @"b") b $-                RCons (Proxy @"c") c $-                  RNil,-      icBuild = \(RCons _ a (RCons _ b (RCons _ c RNil))) -> Begin a b c-    }--wcAB :: WireCtor MultiOutput (Int, (Int, ()))-wcAB =-  WireCtor-    { wcName = "OutAB",-      wcMatch = \case-        OutAB a b -> Just (a, (b, ()))-        _ -> Nothing,-      wcBuild = \(a, (b, ())) -> OutAB a b-    }--wcBC :: WireCtor MultiOutput (Int, (Int, ()))-wcBC =-  WireCtor-    { wcName = "OutBC",-      wcMatch = \case-        OutBC b c -> Just (b, (c, ()))-        _ -> Nothing,-      wcBuild = \(b, (c, ())) -> OutBC b c-    }--wcA :: WireCtor MultiOutput (Int, ())-wcA =-  WireCtor-    { wcName = "OutA",-      wcMatch = \case-        OutA a -> Just (a, ())-        _ -> Nothing,-      wcBuild = \(a, ()) -> OutA a-    }---- | The "well-formed" multi-event edge: two OPacks whose union of--- visited slots covers all three of @Begin@'s slots.--- OPack #1 (OutAB) visits {a, b}; OPack #2 (OutBC) visits {b, c}.--- Union = {a, b, c} = full InCtor coverage. No warning expected.-goodUnion :: SymTransducer (HsPred '[] MultiInput) '[] Bool MultiInput MultiOutput-goodUnion =-  SymTransducer-    { edgesOut = \case-        False ->-          [ Edge-              { guard = matchInCtor inCtorBegin,-                update = UKeep,-                output =-                  [ pack-                      inCtorBegin-                      wcAB-                      ( OFCons-                          (TInpCtorField inCtorBegin (#a :: Index '[ '("a", Int), '("b", Int), '("c", Int)] Int))-                          (OFCons (TInpCtorField inCtorBegin (#b :: Index '[ '("a", Int), '("b", Int), '("c", Int)] Int)) OFNil)-                      ),-                    pack-                      inCtorBegin-                      wcBC-                      ( OFCons-                          (TInpCtorField inCtorBegin (#b :: Index '[ '("a", Int), '("b", Int), '("c", Int)] Int))-                          (OFCons (TInpCtorField inCtorBegin (#c :: Index '[ '("a", Int), '("b", Int), '("c", Int)] Int)) OFNil)-                      )-                  ],-                target = True-              }-          ]-        True -> [],-      initial = False,-      initialRegs = RNil,-      isFinal = id-    }---- | The "ill-formed" multi-event edge: two OPacks whose union of--- visited slots is {a, b}, leaving slot @c@ unrecovered. Both OPacks--- name the same InCtor (@Begin@); the union check should flag @c@.-badUnion :: SymTransducer (HsPred '[] MultiInput) '[] Bool MultiInput MultiOutput-badUnion =-  SymTransducer-    { edgesOut = \case-        False ->-          [ Edge-              { guard = matchInCtor inCtorBegin,-                update = UKeep,-                output =-                  [ pack-                      inCtorBegin-                      wcAB-                      ( OFCons-                          (TInpCtorField inCtorBegin (#a :: Index '[ '("a", Int), '("b", Int), '("c", Int)] Int))-                          (OFCons (TInpCtorField inCtorBegin (#b :: Index '[ '("a", Int), '("b", Int), '("c", Int)] Int)) OFNil)-                      ),-                    pack-                      inCtorBegin-                      wcA-                      (OFCons (TInpCtorField inCtorBegin (#a :: Index '[ '("a", Int), '("b", Int), '("c", Int)] Int)) OFNil)-                  ],-                target = True-              }-          ]-        True -> [],-      initial = False,-      initialRegs = RNil,-      isFinal = id-    }---- | A single-event edge that does NOT cover all slots of its InCtor.--- Legacy behaviour: the per-OPack check fires. Confirms the union--- check is a strict generalisation (not a regression) of the legacy--- single-event check.-badSingle :: SymTransducer (HsPred '[] MultiInput) '[] Bool MultiInput MultiOutput-badSingle =-  SymTransducer-    { edgesOut = \case-        False ->-          [ Edge-              { guard = matchInCtor inCtorBegin,-                update = UKeep,-                output =-                  [ pack-                      inCtorBegin-                      wcA-                      (OFCons (TInpCtorField inCtorBegin (#a :: Index '[ '("a", Int), '("b", Int), '("c", Int)] Int)) OFNil)-                  ],-                target = True-              }-          ]-        True -> [],-      initial = False,-      initialRegs = RNil,-      isFinal = id-    }+falseEdge ::+  SymTransducer (HsPred '[] SplitCmd) '[] Bool SplitCmd SplitEvent ->+  Edge (HsPred '[] SplitCmd) '[] SplitCmd SplitEvent Bool+falseEdge t = case edgesOut t False of+  [edge] -> edge+  _ -> error "SplitCoverage fixture must have exactly one edge from False"  spec :: Spec spec = do-  describe "checkHiddenInputs union strengthening (EP-19 M4)" $ do-    it "well-formed multi-event edge (union covers all slots) ⇒ no warnings" $-      checkHiddenInputs goodUnion `shouldBe` []--    it "ill-formed multi-event edge (union still misses slot c) ⇒ warning names c" $ do-      let warnings = checkHiddenInputs badUnion+  describe "checkHiddenInputs head recoverability (EP-71)" $ do+    it "split coverage reports tail-only slot c" $ do+      let reasons = hiddenInputReasons (falseEdge splitCoverageBad)+      reasons `shouldBe` [HirHeadUnrecoverable "Begin" ["c"]]+      let warnings = checkHiddenInputs splitCoverageBad       length warnings `shouldBe` 1       case warnings of         [w] -> do           hiwEdgeSource w `shouldBe` "False"-          hiwReason w `shouldSatisfy` ("Begin" `isInfixOf`)+          hiwReason w `shouldSatisfy` ("head event" `isInfixOf`)           hiwReason w `shouldSatisfy` ("\"c\"" `isInfixOf`)         _ -> expectationFailure "expected exactly one warning" -    it "single-event edge missing slots fires too (legacy compat)" $ do-      let warnings = checkHiddenInputs badSingle-      length warnings `shouldBe` 1-      case warnings of-        [w] -> do-          hiwEdgeSource w `shouldBe` "False"-          hiwReason w `shouldSatisfy` ("Begin" `isInfixOf`)-          -- Legacy behaviour: missing both b and c.-          hiwReason w `shouldSatisfy` ("\"b\"" `isInfixOf`)-          hiwReason w `shouldSatisfy` ("\"c\"" `isInfixOf`)-        _ -> expectationFailure "expected exactly one warning"+    it "union miss keeps naming off-wire slot c" $ do+      let reasons = hiddenInputReasons (falseEdge splitCoverageUnionMiss)+      reasons `shouldBe` [HirUnionMiss "Begin" ["c"]]++    it "single-event miss keeps naming b and c" $ do+      let reasons = hiddenInputReasons (falseEdge splitCoverageSingleMiss)+      reasons `shouldBe` [HirUnionMiss "Begin" ["b", "c"]]++    it "a head-complete multi-event edge is clean" $+      hiddenInputReasons (falseEdge splitCoverageFixed) `shouldBe` []++    it "structured validation reports HeadUnrecoverable" $ do+      let warnings = validateTransducer defaultValidationOptions splitCoverageBad+          isHeadWarning+            ( HeadUnrecoverable+                { tvwEdge = EdgeRef {edgeSource = False, edgeIndex = 0},+                  tvwInCtor = Just "Begin",+                  tvwTailOnlySlots = ["c"]+                }+              ) = True+          isHeadWarning _ = False+      warnings `shouldSatisfy` any isHeadWarning
test/Keiki/CoreSpec.hs view
@@ -8,6 +8,11 @@ -- | A two-constructor input symbol used by the 'TInpCtorField' tests. data TinyCmd = TinyFoo Int Int | TinyBar Int deriving (Eq, Show) +type SnapshotRegs =+  '[ '("x", Int),+     '("y", Int)+   ]+ inCtorTinyFoo ::   InCtor     TinyCmd@@ -131,6 +136,32 @@       evalPred (PBot :: HsPred '[] ()) RNil () `shouldBe` False     it "PEq compares equal terms" $       evalPred (TLit (1 :: Int) .== TLit 1 :: HsPred '[] ()) RNil () `shouldBe` True++  describe "runUpdate snapshot semantics" $ do+    let regs = RCons (Proxy @"x") 0 (RCons (Proxy @"y") 99 RNil)++    it "evaluates sibling right-hand sides against the entry snapshot" $ do+      let update :: Update SnapshotRegs '["x", "y"] ()+          update =+            UCombine+              (USet (#x :: IndexN "x" SnapshotRegs Int) (lit 1))+              ( USet+                  (#y :: IndexN "y" SnapshotRegs Int)+                  (proj (#x :: Index SnapshotRegs Int))+              )+          result = runUpdate update regs ()+      result ! (#x :: Index SnapshotRegs Int) `shouldBe` 1+      result ! (#y :: Index SnapshotRegs Int) `shouldBe` 0++    it "preserves self-read increments" $ do+      let update :: Update SnapshotRegs '["x"] ()+          update =+            USet+              (#x :: IndexN "x" SnapshotRegs Int)+              (proj (#x :: Index SnapshotRegs Int) .+ lit 1)+          result = runUpdate update regs ()+      result ! (#x :: Index SnapshotRegs Int) `shouldBe` 1+      result ! (#y :: Index SnapshotRegs Int) `shouldBe` 99    describe "synthetic 2-vertex transducer" $ do     it "delta moves False -> True on input True (state)" $
− test/Keiki/DeciderSpec.hs
@@ -1,152 +0,0 @@-module Keiki.DeciderSpec (spec) where--import Data.Time (UTCTime (..), fromGregorian, secondsToDiffTime)-import Keiki.Core-import Keiki.Decider-import Keiki.Fixtures.UserRegistration-import Test.Hspec---- | Same time fixture as 'Keiki.Fixtures.UserRegistrationSpec' so the--- decider round-trip lands on the snapshot 'reconstitute' produces.-t :: Integer -> UTCTime-t s = UTCTime (fromGregorian 2026 5 1) (secondsToDiffTime s)--type Snapshot = (Email, ConfirmationCode, UTCTime, UTCTime, UTCTime)--snapshot :: RegFile UserRegRegs -> Snapshot-snapshot regs =-  ( regs ! #email,-    regs ! #confirmCode,-    regs ! #registeredAt,-    regs ! #confirmedAt,-    regs ! #deletedAt-  )---- | The forward command sequence whose 'omega' trace matches--- 'Keiki.Fixtures.UserRegistrationSpec.canonicalLog'. The reconstitute--- spec fixes the events; this fixture records the inputs that produce--- them on the User Registration edge graph.--- EP-19 M7: the entrance now drives PotentialCustomer →--- RequiresConfirmation in one transition emitting two events, so--- there is no separate Continue command in the sequence.-canonicalCmds :: [UserCmd]-canonicalCmds =-  [ StartRegistration (StartRegistrationData "alice@x" "Z9F4" (t 0)),-    ResendConfirmation (ResendConfirmationData "K2P7" (t 100)),-    ConfirmAccount (ConfirmAccountData "K2P7" (t 200)),-    FulfillGDPRRequest (FulfillGDPRRequestData (t 300))-  ]---- | Hand-computed snapshot at the end of replay. Same values as--- 'Keiki.Fixtures.UserRegistrationSpec.expectedSnapshot' so the two--- specs validate the same end-state from opposite directions.-expectedSnapshot :: Snapshot-expectedSnapshot =-  ( "alice@x",-    "K2P7",-    t 100, -- registeredAt rotated by ResendConfirmation-    t 200, -- confirmedAt-    t 300 -- deletedAt-  )---- | Run one decide/evolve round on the (s, regs) pair: the façade--- emits zero or one event (on the state-refinement form), and--- 'evolve' folds the (zero or one) event back into the state.-runRound ::-  Decider-    UserCmd-    UserEvent-    (Vertex, RegFile UserRegRegs)-    (InFlight Vertex UserEvent, RegFile UserRegRegs) ->-  (Vertex, RegFile UserRegRegs) ->-  UserCmd ->-  (Vertex, RegFile UserRegRegs)-runRound d acc cmd = foldl (evolve d) acc (decide d cmd acc)--spec :: Spec-spec = do-  describe "toDecider userReg" $ do-    it "round-trips the canonical command sequence to (Deleted, expectedSnapshot)" $ do-      let d = toDecider userReg-          (sFinal, regsFinal) = foldl (runRound d) (initialState d) canonicalCmds-      (sFinal, snapshot regsFinal) `shouldBe` (Deleted, expectedSnapshot)--    it "isTerminal d reports True after the canonical sequence" $ do-      let d = toDecider userReg-          end = foldl (runRound d) (initialState d) canonicalCmds-      isTerminal d end `shouldBe` True--    it "decide on the very first command emits the multi-event chain [RegistrationStarted, ConfirmationEmailSent]" $ do-      -- EP-19 M7: the entrance is now a length-2 multi-event edge,-      -- so decide returns both events from StartRegistration in-      -- declaration order.-      let d = toDecider userReg-          evs = decide d (head canonicalCmds) (initialState d)-      length evs `shouldBe` 2-      case evs of-        [RegistrationStarted _, ConfirmationEmailSent _] -> pure ()-        _ -> expectationFailure ("unexpected event sequence: " <> show evs)--    it "ε-edge limitation: GDPR from RequiresConfirmation yields [] from decide" $ do-      -- Drive the aggregate to RequiresConfirmation by chunk-replaying-      -- StartRegistration's two-event chain, then attempt the silent-      -- ε-edge (FulfillGDPRRequest before the user has confirmed).-      let d = toDecider userReg-          startCmd = StartRegistration (StartRegistrationData "bob@x" "S0E1" (t 0))-          startEvs = decide d startCmd (initialState d)-          preGdpr = case applyEvents userReg (initialState d) startEvs of-            Just sR -> sR-            Nothing -> error "applyEvents on the 2-event chain failed"-          gdprCmd = FulfillGDPRRequest (FulfillGDPRRequestData (t 999))-          evs = decide d gdprCmd preGdpr-          afterGdpr = foldl (evolve d) preGdpr evs-      length startEvs `shouldBe` 2-      fst preGdpr `shouldBe` RequiresConfirmation-      evs `shouldBe` []-      -- The ε-edge limitation: with no event, evolve is a no-op, so-      -- the façade leaves the state at RequiresConfirmation even-      -- though the keiki delta would transition to Deleted.-      fst afterGdpr `shouldBe` RequiresConfirmation--    it "ε-edge cross-check: delta does transition the same input to Deleted" $ do-      -- Companion to the previous case: confirm that the keiki-      -- transducer itself can drive the ε-edge via 'delta'. The point-      -- is that the limitation lives at the façade boundary, not in-      -- the underlying transducer.-      let d = toDecider userReg-          startCmd = StartRegistration (StartRegistrationData "carol@x" "T1V2" (t 0))-          startEvs = decide d startCmd (initialState d)-          preGdpr = case applyEvents userReg (initialState d) startEvs of-            Just sR -> sR-            Nothing -> error "applyEvents on the 2-event chain failed"-          (vAtRC, regsAtRC) = preGdpr-          gdprCmd = FulfillGDPRRequest (FulfillGDPRRequestData (t 999))-      vAtRC `shouldBe` RequiresConfirmation-      case delta userReg vAtRC regsAtRC gdprCmd of-        Just (vNext, _) -> vNext `shouldBe` Deleted-        Nothing -> expectationFailure "delta returned Nothing"--  describe "evolveStreaming (EP-19 M5)" $ do-    it "Settled PotentialCustomer ⊢ RegistrationStarted → InFlight RequiresConfirmation [ConfirmationEmailSent]" $ do-      -- After EP-19 M7 collapsed the entrance into a length-2-      -- multi-event edge, streaming replay through its head event-      -- transitions to the mid-chain wrapper carrying the expected-      -- tail event. The second event then unwraps to Settled.-      let d = toDecider userReg-          (_, regs0) = initialState d-          ev =-            RegistrationStarted-              (RegistrationStartedData "dave@x" "U2V3" (t 0))-      case evolveStreaming d (Settled PotentialCustomer, regs0) ev of-        Just (InFlight RequiresConfirmation [ConfirmationEmailSent _], _) -> pure ()-        Just (other, _) ->-          expectationFailure ("expected InFlight RequiresConfirmation [...], got " <> show other)-        Nothing -> expectationFailure "evolveStreaming returned Nothing"--    it "Settled Confirmed ⊢ AccountDeleted → Settled Deleted" $ do-      let d = toDecider userReg-          (_, regs0) = initialState d-          ev = AccountDeleted (AccountDeletedData "x@y" (t 200))-      case evolveStreaming d (Settled Confirmed, regs0) ev of-        Just (Settled Deleted, _) -> pure ()-        other -> expectationFailure ("unexpected: " <> show (fmap fst other))
+ test/Keiki/Fixtures/BrokenTailCoverage.hs view
@@ -0,0 +1,130 @@+-- | Deliberately defective EP-73 fixture. Its command coverage is split+-- across two events, so the first event cannot reconstruct the command.+-- This module exists only to prove the round-trip harness and validator catch+-- EP-71's head-recoverability defect class. It is not an authoring example.+module Keiki.Fixtures.BrokenTailCoverage+  ( ProvisionData (..),+    BrokenCommand (..),+    BrokenEvent (..),+    BrokenVertex (..),+    BrokenRegs,+    brokenTailCoverage,+  )+where++import Data.Proxy (Proxy (..))+import Data.Text (Text)+import Keiki.Core++data ProvisionData = ProvisionData+  { owner :: Text,+    quota :: Int+  }+  deriving stock (Eq, Show)++data BrokenCommand = Provision ProvisionData+  deriving stock (Eq, Show)++newtype OwnerRecordedData = OwnerRecordedData {owner :: Text}+  deriving stock (Eq, Show)++newtype QuotaAssignedData = QuotaAssignedData {quota :: Int}+  deriving stock (Eq, Show)++data BrokenEvent+  = OwnerRecorded OwnerRecordedData+  | QuotaAssigned QuotaAssignedData+  deriving stock (Eq, Show)++data BrokenVertex = BtcIdle | BtcProvisioned+  deriving stock (Eq, Ord, Show, Enum, Bounded)++type BrokenRegs =+  '[ '("owner", Text),+     '("quota", Int)+   ]++type ProvisionFields =+  '[ '("owner", Text),+     '("quota", Int)+   ]++inCtorProvision :: InCtor BrokenCommand ProvisionFields+inCtorProvision =+  InCtor+    { icName = "Provision",+      icMatch = \case+        Provision ProvisionData {owner, quota} ->+          Just $+            RCons (Proxy @"owner") owner $+              RCons (Proxy @"quota") quota RNil,+      icBuild = \(RCons _ owner (RCons _ quota RNil)) ->+        Provision ProvisionData {owner, quota}+    }++wireOwnerRecorded :: WireCtor BrokenEvent (Text, ())+wireOwnerRecorded =+  WireCtor+    { wcName = "OwnerRecorded",+      wcMatch = \case+        OwnerRecorded OwnerRecordedData {owner} -> Just (owner, ())+        _ -> Nothing,+      wcBuild = \(owner, ()) -> OwnerRecorded OwnerRecordedData {owner}+    }++wireQuotaAssigned :: WireCtor BrokenEvent (Int, ())+wireQuotaAssigned =+  WireCtor+    { wcName = "QuotaAssigned",+      wcMatch = \case+        QuotaAssigned QuotaAssignedData {quota} -> Just (quota, ())+        _ -> Nothing,+      wcBuild = \(quota, ()) -> QuotaAssigned QuotaAssignedData {quota}+    }++provisionOwner :: Term BrokenRegs BrokenCommand ProvisionFields Text+provisionOwner = TInpCtorField inCtorProvision (#owner :: Index ProvisionFields Text)++provisionQuota :: Term BrokenRegs BrokenCommand ProvisionFields Int+provisionQuota = TInpCtorField inCtorProvision (#quota :: Index ProvisionFields Int)++brokenTailCoverage ::+  SymTransducer+    (HsPred BrokenRegs BrokenCommand)+    BrokenRegs+    BrokenVertex+    BrokenCommand+    BrokenEvent+brokenTailCoverage =+  SymTransducer+    { initial = BtcIdle,+      initialRegs =+        RCons (Proxy @"owner") "" $+          RCons (Proxy @"quota") 0 RNil,+      isFinal = (== BtcProvisioned),+      edgesOut = \case+        BtcIdle ->+          [ Edge+              { guard = matchInCtor inCtorProvision,+                update =+                  USet+                    (#owner :: IndexN "owner" BrokenRegs Text)+                    provisionOwner+                    `combine` USet+                      (#quota :: IndexN "quota" BrokenRegs Int)+                      provisionQuota,+                output =+                  [ pack+                      inCtorProvision+                      wireOwnerRecorded+                      (provisionOwner *: oNil),+                    pack+                      inCtorProvision+                      wireQuotaAssigned+                      (provisionQuota *: oNil)+                  ],+                target = BtcProvisioned+              }+          ]+        BtcProvisioned -> []+    }
+ test/Keiki/Fixtures/ComposeStateful.hs view
@@ -0,0 +1,366 @@+-- | Stateful fixtures for EP-74's sequential-composition regressions.+-- Keep these transducers independent of hspec so later property suites can+-- compare 'compose' with an explicit sequential reference.+module Keiki.Fixtures.ComposeStateful+  ( SourceCmd (..),+    MidVal (..),+    OutVal (..),+    StageOut (..),+    PairCmd (..),+    Mid2 (..),+    WrongOut (..),+    M2SourceCmd (..),+    CounterRegs,+    SinkRegs,+    PhaseRegs,+    CounterVertex (..),+    SinkVertex (..),+    PhaseVertex (..),+    PairVertex (..),+    M2SourceVertex (..),+    WrongVertex (..),+    counterSource,+    lastValueSink,+    pairSource,+    twoPhaseSink,+    m2aSource,+    wrongOrderSink,+    readSourceCount,+    readSinkLast,+    readPhase,+  )+where++import Data.Proxy (Proxy (..))+import Keiki.Core+import Keiki.Generics (Append)++data SourceCmd = Tick+  deriving stock (Eq, Show)++data MidVal = MidVal Int+  deriving stock (Eq, Show)++data OutVal = OutVal Int+  deriving stock (Eq, Show)++data StageOut = Stage1 Int | Stage2 Int+  deriving stock (Eq, Show)++data PairCmd = Go+  deriving stock (Eq, Show)++data M2SourceCmd = ProduceA+  deriving stock (Eq, Show)++data Mid2 = M2A Int | M2B Int+  deriving stock (Eq, Show)++data WrongOut = SawA Int | SawB Int+  deriving stock (Eq, Show)++type CounterRegs = '[ '("srcCount", Int)]++type SinkRegs = '[ '("sinkLast", Int)]++type PhaseRegs = '[ '("phase", Int)]++data CounterVertex = CounterVertex+  deriving stock (Eq, Ord, Show, Enum, Bounded)++data SinkVertex = SinkVertex+  deriving stock (Eq, Ord, Show, Enum, Bounded)++data PairVertex = PairVertex+  deriving stock (Eq, Ord, Show, Enum, Bounded)++data PhaseVertex = PhaseVertex+  deriving stock (Eq, Ord, Show, Enum, Bounded)++data M2SourceVertex = M2SourceVertex+  deriving stock (Eq, Ord, Show, Enum, Bounded)++data WrongVertex = WrongVertex+  deriving stock (Eq, Ord, Show, Enum, Bounded)++inCtorTick :: InCtor SourceCmd '[]+inCtorTick =+  InCtor+    { icName = "Tick",+      icMatch = \case Tick -> Just RNil,+      icBuild = \RNil -> Tick+    }++inCtorGo :: InCtor PairCmd '[]+inCtorGo =+  InCtor+    { icName = "Go",+      icMatch = \case Go -> Just RNil,+      icBuild = \RNil -> Go+    }++inCtorProduceA :: InCtor M2SourceCmd '[]+inCtorProduceA =+  InCtor+    { icName = "ProduceA",+      icMatch = \case ProduceA -> Just RNil,+      icBuild = \RNil -> ProduceA+    }++inCtorMidVal :: InCtor MidVal '[ '("v", Int)]+inCtorMidVal =+  InCtor+    { icName = "MidVal",+      icMatch = \case MidVal v -> Just (RCons (Proxy @"v") v RNil),+      icBuild = \(RCons _ v RNil) -> MidVal v+    }++inCtorM2A :: InCtor Mid2 '[ '("a", Int)]+inCtorM2A =+  InCtor+    { icName = "M2A",+      icMatch = \case+        M2A a -> Just (RCons (Proxy @"a") a RNil)+        M2B _ -> Nothing,+      icBuild = \(RCons _ a RNil) -> M2A a+    }++inCtorM2B :: InCtor Mid2 '[ '("b", Int)]+inCtorM2B =+  InCtor+    { icName = "M2B",+      icMatch = \case+        M2A _ -> Nothing+        M2B b -> Just (RCons (Proxy @"b") b RNil),+      icBuild = \(RCons _ b RNil) -> M2B b+    }++wireMidVal :: WireCtor MidVal (Int, ())+wireMidVal =+  WireCtor+    { wcName = "MidVal",+      wcMatch = \case MidVal v -> Just (v, ()),+      wcBuild = \(v, ()) -> MidVal v+    }++wireM2A :: WireCtor Mid2 (Int, ())+wireM2A =+  WireCtor+    { wcName = "M2A",+      wcMatch = \case+        M2A a -> Just (a, ())+        M2B _ -> Nothing,+      wcBuild = \(a, ()) -> M2A a+    }++wireOutVal :: WireCtor OutVal (Int, ())+wireOutVal =+  WireCtor+    { wcName = "OutVal",+      wcMatch = \case OutVal v -> Just (v, ()),+      wcBuild = \(v, ()) -> OutVal v+    }++wireStage1 :: WireCtor StageOut (Int, ())+wireStage1 =+  WireCtor+    { wcName = "Stage1",+      wcMatch = \case+        Stage1 v -> Just (v, ())+        Stage2 _ -> Nothing,+      wcBuild = \(v, ()) -> Stage1 v+    }++wireStage2 :: WireCtor StageOut (Int, ())+wireStage2 =+  WireCtor+    { wcName = "Stage2",+      wcMatch = \case+        Stage1 _ -> Nothing+        Stage2 v -> Just (v, ()),+      wcBuild = \(v, ()) -> Stage2 v+    }++wireSawA :: WireCtor WrongOut (Int, ())+wireSawA =+  WireCtor+    { wcName = "SawA",+      wcMatch = \case+        SawA v -> Just (v, ())+        SawB _ -> Nothing,+      wcBuild = \(v, ()) -> SawA v+    }++wireSawB :: WireCtor WrongOut (Int, ())+wireSawB =+  WireCtor+    { wcName = "SawB",+      wcMatch = \case+        SawA _ -> Nothing+        SawB v -> Just (v, ()),+      wcBuild = \(v, ()) -> SawB v+    }++counterSource :: SymTransducer (HsPred CounterRegs SourceCmd) CounterRegs CounterVertex SourceCmd MidVal+counterSource =+  SymTransducer+    { edgesOut = \CounterVertex ->+        [ Edge+            { guard = matchInCtor inCtorTick,+              update =+                USet+                  (#srcCount :: IndexN "srcCount" CounterRegs Int)+                  (proj (#srcCount :: Index CounterRegs Int) .+ lit 1),+              output =+                [ pack+                    inCtorTick+                    wireMidVal+                    (proj (#srcCount :: Index CounterRegs Int) *: oNil)+                ],+              target = CounterVertex+            }+        ],+      initial = CounterVertex,+      initialRegs = RCons (Proxy @"srcCount") 0 RNil,+      isFinal = const True+    }++lastValueSink :: SymTransducer (HsPred SinkRegs MidVal) SinkRegs SinkVertex MidVal OutVal+lastValueSink =+  SymTransducer+    { edgesOut = \SinkVertex ->+        [ Edge+            { guard = matchInCtor inCtorMidVal,+              update =+                USet+                  (#sinkLast :: IndexN "sinkLast" SinkRegs Int)+                  (inpCtor inCtorMidVal (#v :: Index '[ '("v", Int)] Int)),+              output =+                [ pack+                    inCtorMidVal+                    wireOutVal+                    (inpCtor inCtorMidVal (#v :: Index '[ '("v", Int)] Int) *: oNil)+                ],+              target = SinkVertex+            }+        ],+      initial = SinkVertex,+      initialRegs = RCons (Proxy @"sinkLast") (-1) RNil,+      isFinal = const True+    }++pairSource :: SymTransducer (HsPred '[] PairCmd) '[] PairVertex PairCmd MidVal+pairSource =+  SymTransducer+    { edgesOut = \PairVertex ->+        [ Edge+            { guard = matchInCtor inCtorGo,+              update = UKeep,+              output =+                [ pack inCtorGo wireMidVal (lit 10 *: oNil),+                  pack inCtorGo wireMidVal (lit 20 *: oNil)+                ],+              target = PairVertex+            }+        ],+      initial = PairVertex,+      initialRegs = RNil,+      isFinal = const True+    }++twoPhaseSink :: SymTransducer (HsPred PhaseRegs MidVal) PhaseRegs PhaseVertex MidVal StageOut+twoPhaseSink =+  SymTransducer+    { edgesOut = \PhaseVertex ->+        [ Edge+            { guard =+                matchInCtor inCtorMidVal+                  .&& (proj (#phase :: Index PhaseRegs Int) .== lit 0),+              update = USet (#phase :: IndexN "phase" PhaseRegs Int) (lit 1),+              output =+                [ pack+                    inCtorMidVal+                    wireStage1+                    (inpCtor inCtorMidVal (#v :: Index '[ '("v", Int)] Int) *: oNil)+                ],+              target = PhaseVertex+            },+          Edge+            { guard =+                matchInCtor inCtorMidVal+                  .&& (proj (#phase :: Index PhaseRegs Int) .== lit 1),+              update = USet (#phase :: IndexN "phase" PhaseRegs Int) (lit 2),+              output =+                [ pack+                    inCtorMidVal+                    wireStage2+                    (inpCtor inCtorMidVal (#v :: Index '[ '("v", Int)] Int) *: oNil)+                ],+              target = PhaseVertex+            }+        ],+      initial = PhaseVertex,+      initialRegs = RCons (Proxy @"phase") 0 RNil,+      isFinal = const True+    }++m2aSource :: SymTransducer (HsPred '[] M2SourceCmd) '[] M2SourceVertex M2SourceCmd Mid2+m2aSource =+  SymTransducer+    { edgesOut = \M2SourceVertex ->+        [ Edge+            { guard = matchInCtor inCtorProduceA,+              update = UKeep,+              output = [pack inCtorProduceA wireM2A (lit 5 *: oNil)],+              target = M2SourceVertex+            }+        ],+      initial = M2SourceVertex,+      initialRegs = RNil,+      isFinal = const True+    }++wrongOrderSink :: SymTransducer (HsPred '[] Mid2) '[] WrongVertex Mid2 WrongOut+wrongOrderSink =+  SymTransducer+    { edgesOut = \WrongVertex ->+        [ Edge+            { guard =+                matchInCtor inCtorM2A+                  .&& (inpCtor inCtorM2A (#a :: Index '[ '("a", Int)] Int) .== lit 5),+              update = UKeep,+              output =+                [ pack+                    inCtorM2A+                    wireSawA+                    (inpCtor inCtorM2A (#a :: Index '[ '("a", Int)] Int) *: oNil)+                ],+              target = WrongVertex+            },+          Edge+            { guard =+                (inpCtor inCtorM2B (#b :: Index '[ '("b", Int)] Int) .== lit 5)+                  .&& matchInCtor inCtorM2B,+              update = UKeep,+              output =+                [ pack+                    inCtorM2B+                    wireSawB+                    (inpCtor inCtorM2B (#b :: Index '[ '("b", Int)] Int) *: oNil)+                ],+              target = WrongVertex+            }+        ],+      initial = WrongVertex,+      initialRegs = RNil,+      isFinal = const True+    }++readSourceCount :: RegFile (Append CounterRegs SinkRegs) -> Int+readSourceCount regs = regs ! (#srcCount :: Index (Append CounterRegs SinkRegs) Int)++readSinkLast :: RegFile (Append CounterRegs SinkRegs) -> Int+readSinkLast regs = regs ! (#sinkLast :: Index (Append CounterRegs SinkRegs) Int)++readPhase :: RegFile (Append '[] PhaseRegs) -> Int+readPhase regs = regs ! (#phase :: Index (Append '[] PhaseRegs) Int)
+ test/Keiki/Fixtures/CounterPipeline.hs view
@@ -0,0 +1,153 @@+-- | Three-stage stateful counter pipeline used by the EP-69 Category+-- and Choice regression tests. See the plan at+-- docs/plans/69-replace-the-fabricated-weakenr-and-knownslotnames-dictionary-in-category-composition-with-real-induction-witnesses.md+-- for the two design constraints (mid-alphabet constructor-name+-- alignment; no cross-stage register reads in substituted update RHSs).+module Keiki.Fixtures.CounterPipeline+  ( MsgA (..),+    MsgB (..),+    MsgC (..),+    MsgD (..),+    StageVertex (..),+    ARegs,+    BRegs,+    CRegs,+    stageA,+    stageB,+    stageC,+    stageConflict,+    inMsgB,+    inMsgC,+    inMsgD,+    wireMsgB,+    wireMsgC,+    wireMsgD,+  )+where++import Data.Proxy (Proxy (..))+import GHC.TypeLits (KnownSymbol)+import Keiki.Core++newtype MsgA = MsgA Int deriving stock (Eq, Show)++newtype MsgB = MsgB Int deriving stock (Eq, Show)++newtype MsgC = MsgC Int deriving stock (Eq, Show)++newtype MsgD = MsgD Int deriving stock (Eq, Show)++-- | Every stage is a one-vertex machine that loops on itself.+data StageVertex = StageVertex deriving stock (Eq, Ord, Show, Bounded, Enum)++type ARegs = '[ '("regA", Int)]++type BRegs = '[ '("regB", Int)]++type CRegs = '[ '("regC", Int)]++-- | One-field input schema shared by all pipeline messages.+type PayloadSchema = '[ '("payload", Int)]++mkInCtor :: String -> (msg -> Int) -> (Int -> msg) -> InCtor msg PayloadSchema+mkInCtor name unwrap rebuild =+  InCtor+    { icName = name,+      icMatch = \m -> Just (RCons (Proxy @"payload") (unwrap m) RNil),+      icBuild = \(RCons _ n RNil) -> rebuild n+    }++mkWireCtor :: String -> (msg -> Int) -> (Int -> msg) -> WireCtor msg (Int, ())+mkWireCtor name unwrap rebuild =+  WireCtor+    { wcName = name,+      wcMatch = \m -> Just (unwrap m, ()),+      wcBuild = \(n, ()) -> rebuild n+    }++inMsgA :: InCtor MsgA PayloadSchema+inMsgA = mkInCtor "MsgA" (\(MsgA n) -> n) MsgA++inMsgB :: InCtor MsgB PayloadSchema+inMsgB = mkInCtor "MsgB" (\(MsgB n) -> n) MsgB++inMsgC :: InCtor MsgC PayloadSchema+inMsgC = mkInCtor "MsgC" (\(MsgC n) -> n) MsgC++inMsgD :: InCtor MsgD PayloadSchema+inMsgD = mkInCtor "MsgD" (\(MsgD n) -> n) MsgD++wireMsgB :: WireCtor MsgB (Int, ())+wireMsgB = mkWireCtor "MsgB" (\(MsgB n) -> n) MsgB++wireMsgC :: WireCtor MsgC (Int, ())+wireMsgC = mkWireCtor "MsgC" (\(MsgC n) -> n) MsgC++wireMsgD :: WireCtor MsgD (Int, ())+wireMsgD = mkWireCtor "MsgD" (\(MsgD n) -> n) MsgD++-- | Shared stage shape: guard reads the register (a real read, always+-- satisfied for this fixture's inputs); update accumulates the input+-- payload into the register; output is the caller-supplied field term.+counterStage ::+  forall name inMsg outMsg.+  (KnownSymbol name) =>+  InCtor inMsg PayloadSchema ->+  WireCtor outMsg (Int, ()) ->+  ( Term '[ '(name, Int)] inMsg PayloadSchema Int ->+    Term '[ '(name, Int)] inMsg PayloadSchema Int+  ) ->+  SymTransducer+    (HsPred '[ '(name, Int)] inMsg)+    '[ '(name, Int)]+    StageVertex+    inMsg+    outMsg+counterStage ic wc mkField =+  SymTransducer+    { edgesOut = \StageVertex ->+        [ Edge+            { guard =+                PAnd+                  (PInCtor ic)+                  (PCmp CmpGe (TReg ZIdx) (TLit (0 :: Int))),+              update = USet IZ (tadd (TReg ZIdx) (TInpCtorField ic ZIdx)),+              output =+                [pack ic wc (OFCons (mkField (TInpCtorField ic ZIdx)) OFNil)],+              target = StageVertex+            }+        ],+      initial = StageVertex,+      initialRegs = RCons (Proxy @name) 0 RNil,+      isFinal = const True+    }++-- | Stage a: doubles the payload; accumulates inputs into @regA@.+stageA :: SymTransducer (HsPred ARegs MsgA) ARegs StageVertex MsgA MsgB+stageA = counterStage inMsgA wireMsgB (\p -> tmul p (lit 2))++-- | Stage b: increments the payload; accumulates inputs into @regB@.+stageB :: SymTransducer (HsPred BRegs MsgB) BRegs StageVertex MsgB MsgC+stageB = counterStage inMsgB wireMsgC (\p -> tadd p (lit 1))++-- | Stage c adds its own accumulator to the payload. The register+-- read detects a composite dictionary that fails to shift past the+-- upstream slots.+stageC :: SymTransducer (HsPred CRegs MsgC) CRegs StageVertex MsgC MsgD+stageC = counterStage inMsgC wireMsgD (\p -> tadd p (TReg ZIdx))++-- | A @MsgD -> MsgD@ stage that deliberately reuses stage a's slot+-- name. Composing it after a pipeline containing stage a must raise+-- 'Keiki.Profunctor.CategoryOverlapError'.+stageConflict ::+  SymTransducer+    (HsPred '[ '("regA", Int)] MsgD)+    '[ '("regA", Int)]+    StageVertex+    MsgD+    MsgD+stageConflict =+  counterStage+    inMsgD+    (mkWireCtor "MsgDOut" (\(MsgD n) -> n) MsgD)+    id
test/Keiki/Fixtures/EmailDelivery.hs view
@@ -99,7 +99,7 @@    ]  data EmailVertex = EmailPending | EmailSentVertex-  deriving (Eq, Show, Enum, Bounded)+  deriving (Eq, Ord, Show, Enum, Bounded)  -- | Initial register file. Each slot is pre-bound to a deferred -- @"uninit: <slot>"@ error by 'Keiki.Generics.emptyRegFile'.
+ test/Keiki/Fixtures/RegisterEmission.hs view
@@ -0,0 +1,140 @@+-- | Shared fixture, integration point 4 of+-- @docs/masterplans/16-harden-keiki-correctness-and-api-surfaces-surfaced-by-the-2026-07-architecture-review.md@:+-- consumed by EP-71 (validation alignment), EP-73 (round-trip property+-- harness), and EP-74 (composition semantics). Do not fold into a spec module.+module Keiki.Fixtures.RegisterEmission+  ( RegisterCmd (..),+    RegisterEvent (..),+    RegisterVertex (..),+    RegisterEmissionRegs,+    registerEmission,+    registerCommands,+  )+where++import Data.Proxy (Proxy (..))+import Data.Text (Text)+import Keiki.Core++data RegisterCmd+  = Open Text+  | Add Int+  | Close+  deriving stock (Eq, Show)++data RegisterEvent+  = Opened Text+  | Added Int Text+  | Closed Text+  | Archived Text+  deriving stock (Eq, Show)++data RegisterVertex = Fresh | Active | Finished+  deriving stock (Eq, Ord, Show, Enum, Bounded)++type RegisterEmissionRegs =+  '[ '("owner", Text),+     '("total", Int)+   ]++inCtorOpen :: InCtor RegisterCmd '[ '("owner", Text)]+inCtorOpen =+  InCtor+    { icName = "Open",+      icMatch = \case Open owner -> Just (RCons (Proxy @"owner") owner RNil); _ -> Nothing,+      icBuild = \(RCons _ owner RNil) -> Open owner+    }++inCtorAdd :: InCtor RegisterCmd '[ '("amount", Int)]+inCtorAdd =+  InCtor+    { icName = "Add",+      icMatch = \case Add amount -> Just (RCons (Proxy @"amount") amount RNil); _ -> Nothing,+      icBuild = \(RCons _ amount RNil) -> Add amount+    }++inCtorClose :: InCtor RegisterCmd '[]+inCtorClose =+  InCtor+    { icName = "Close",+      icMatch = \case Close -> Just RNil; _ -> Nothing,+      icBuild = \RNil -> Close+    }++wireOpened :: WireCtor RegisterEvent (Text, ())+wireOpened =+  WireCtor+    { wcName = "Opened",+      wcMatch = \case Opened owner -> Just (owner, ()); _ -> Nothing,+      wcBuild = \(owner, ()) -> Opened owner+    }++wireAdded :: WireCtor RegisterEvent (Int, (Text, ()))+wireAdded =+  WireCtor+    { wcName = "Added",+      wcMatch = \case Added amount owner -> Just (amount, (owner, ())); _ -> Nothing,+      wcBuild = \(amount, (owner, ())) -> Added amount owner+    }++wireClosed :: WireCtor RegisterEvent (Text, ())+wireClosed =+  WireCtor+    { wcName = "Closed",+      wcMatch = \case Closed owner -> Just (owner, ()); _ -> Nothing,+      wcBuild = \(owner, ()) -> Closed owner+    }++wireArchived :: WireCtor RegisterEvent (Text, ())+wireArchived =+  WireCtor+    { wcName = "Archived",+      wcMatch = \case Archived owner -> Just (owner, ()); _ -> Nothing,+      wcBuild = \(owner, ()) -> Archived owner+    }++registerEmission :: SymTransducer (HsPred RegisterEmissionRegs RegisterCmd) RegisterEmissionRegs RegisterVertex RegisterCmd RegisterEvent+registerEmission =+  SymTransducer+    { edgesOut = \case+        Fresh ->+          [ Edge+              { guard = matchInCtor inCtorOpen,+                update = USet (#owner :: IndexN "owner" RegisterEmissionRegs Text) (TInpCtorField inCtorOpen (#owner :: Index '[ '("owner", Text)] Text)),+                output = [pack inCtorOpen wireOpened (TInpCtorField inCtorOpen (#owner :: Index '[ '("owner", Text)] Text) *: oNil)],+                target = Active+              }+          ]+        Active ->+          [ Edge+              { guard = matchInCtor inCtorAdd,+                update = USet (#total :: IndexN "total" RegisterEmissionRegs Int) (TInpCtorField inCtorAdd (#amount :: Index '[ '("amount", Int)] Int)),+                output =+                  [ pack+                      inCtorAdd+                      wireAdded+                      ( TInpCtorField inCtorAdd (#amount :: Index '[ '("amount", Int)] Int)+                          *: TReg (#owner :: Index RegisterEmissionRegs Text)+                          *: oNil+                      )+                  ],+                target = Active+              },+            Edge+              { guard = matchInCtor inCtorClose,+                update = UKeep,+                output =+                  [ pack inCtorClose wireClosed (TReg (#owner :: Index RegisterEmissionRegs Text) *: oNil),+                    pack inCtorClose wireArchived (TReg (#owner :: Index RegisterEmissionRegs Text) *: oNil)+                  ],+                target = Finished+              }+          ]+        Finished -> [],+      initial = Fresh,+      initialRegs = RCons (Proxy @"owner") "" (RCons (Proxy @"total") 0 RNil),+      isFinal = (== Finished)+    }++registerCommands :: [RegisterCmd]+registerCommands = [Open "alice", Add 7, Close]
+ test/Keiki/Fixtures/SplitCoverage.hs view
@@ -0,0 +1,142 @@+-- | Shared fixture, integration point 4 of+-- @docs/masterplans/16-harden-keiki-correctness-and-api-surfaces-surfaced-by-the-2026-07-architecture-review.md@:+-- consumed by EP-71 (validation alignment), EP-73 (round-trip property+-- harness), and EP-74 (composition semantics). Do not fold into a spec module.+module Keiki.Fixtures.SplitCoverage+  ( SplitCmd (..),+    SplitEvent (..),+    inCtorBegin,+    wireOutAB,+    wireOutBC,+    wireOutABC,+    wireOutA,+    splitCoverageBad,+    splitCoverageFixed,+    splitCoverageUnionMiss,+    splitCoverageSingleMiss,+  )+where++import Data.Proxy (Proxy (..))+import Keiki.Core++-- | One three-field command used to expose the difference between union+-- coverage and replay's head-only inversion contract.+data SplitCmd = Begin Int Int Int+  deriving stock (Eq, Show)++-- | Event constructors for complete, split, and incomplete coverage shapes.+data SplitEvent+  = OutAB Int Int+  | OutBC Int Int+  | OutABC Int Int Int+  | OutA Int+  deriving stock (Eq, Show)++type BeginFields =+  '[ '("a", Int),+     '("b", Int),+     '("c", Int)+   ]++inCtorBegin :: InCtor SplitCmd BeginFields+inCtorBegin =+  InCtor+    { icName = "Begin",+      icMatch = \case+        Begin a b c ->+          Just $+            RCons (Proxy @"a") a $+              RCons (Proxy @"b") b $+                RCons (Proxy @"c") c RNil,+      icBuild = \(RCons _ a (RCons _ b (RCons _ c RNil))) -> Begin a b c+    }++wireOutAB :: WireCtor SplitEvent (Int, (Int, ()))+wireOutAB =+  WireCtor+    { wcName = "OutAB",+      wcMatch = \case OutAB a b -> Just (a, (b, ())); _ -> Nothing,+      wcBuild = \(a, (b, ())) -> OutAB a b+    }++wireOutBC :: WireCtor SplitEvent (Int, (Int, ()))+wireOutBC =+  WireCtor+    { wcName = "OutBC",+      wcMatch = \case OutBC b c -> Just (b, (c, ())); _ -> Nothing,+      wcBuild = \(b, (c, ())) -> OutBC b c+    }++wireOutABC :: WireCtor SplitEvent (Int, (Int, (Int, ())))+wireOutABC =+  WireCtor+    { wcName = "OutABC",+      wcMatch = \case OutABC a b c -> Just (a, (b, (c, ()))); _ -> Nothing,+      wcBuild = \(a, (b, (c, ()))) -> OutABC a b c+    }++wireOutA :: WireCtor SplitEvent (Int, ())+wireOutA =+  WireCtor+    { wcName = "OutA",+      wcMatch = \case OutA a -> Just (a, ()); _ -> Nothing,+      wcBuild = \(a, ()) -> OutA a+    }++beginA :: Term '[] SplitCmd BeginFields Int+beginA = TInpCtorField inCtorBegin (#a :: Index BeginFields Int)++beginB :: Term '[] SplitCmd BeginFields Int+beginB = TInpCtorField inCtorBegin (#b :: Index BeginFields Int)++beginC :: Term '[] SplitCmd BeginFields Int+beginC = TInpCtorField inCtorBegin (#c :: Index BeginFields Int)++splitTransducer :: [OutTerm '[] SplitCmd SplitEvent] -> SymTransducer (HsPred '[] SplitCmd) '[] Bool SplitCmd SplitEvent+splitTransducer outputs =+  SymTransducer+    { edgesOut = \case+        False ->+          [ Edge+              { guard = matchInCtor inCtorBegin,+                update = UKeep,+                output = outputs,+                target = True+              }+          ]+        True -> [],+      initial = False,+      initialRegs = RNil,+      isFinal = id+    }++-- | Defective shape: the output union covers @a,b,c@, but the head covers+-- only @a,b@, so replay cannot reconstruct @Begin@ from the first event.+splitCoverageBad :: SymTransducer (HsPred '[] SplitCmd) '[] Bool SplitCmd SplitEvent+splitCoverageBad =+  splitTransducer+    [ pack inCtorBegin wireOutAB (beginA *: beginB *: oNil),+      pack inCtorBegin wireOutBC (beginB *: beginC *: oNil)+    ]++-- | Repaired shape: the head event alone covers all command fields.+splitCoverageFixed :: SymTransducer (HsPred '[] SplitCmd) '[] Bool SplitCmd SplitEvent+splitCoverageFixed =+  splitTransducer+    [ pack inCtorBegin wireOutABC (beginA *: beginB *: beginC *: oNil),+      pack inCtorBegin wireOutBC (beginB *: beginC *: oNil)+    ]++-- | The output union still omits @c@ entirely.+splitCoverageUnionMiss :: SymTransducer (HsPred '[] SplitCmd) '[] Bool SplitCmd SplitEvent+splitCoverageUnionMiss =+  splitTransducer+    [ pack inCtorBegin wireOutAB (beginA *: beginB *: oNil),+      pack inCtorBegin wireOutA (beginA *: oNil)+    ]++-- | A one-event edge that omits @b@ and @c@.+splitCoverageSingleMiss :: SymTransducer (HsPred '[] SplitCmd) '[] Bool SplitCmd SplitEvent+splitCoverageSingleMiss =+  splitTransducer [pack inCtorBegin wireOutA (beginA *: oNil)]
test/Keiki/Fixtures/UserRegistration.hs view
@@ -187,7 +187,7 @@   | RequiresConfirmation   | Confirmed   | Deleted-  deriving (Eq, Show, Enum, Bounded)+  deriving (Eq, Ord, Show, Enum, Bounded)  -- | Initial register file. Each slot is pre-bound to a deferred -- @"uninit: <slot>"@ error by 'Keiki.Generics.emptyRegFile' so reads@@ -351,10 +351,16 @@             }         B.goto RequiresConfirmation -      -- GDPR before confirmation: silent ε-edge (no event).+      -- GDPR before confirmation is durable just like post-confirmation+      -- deletion: changing state without an event would be lost on replay.       B.onCmd inCtorGdpr $ \d -> B.do         B.slot @"deletedAt" .= d.at-        B.noEmit+        B.emit+          wireAccountDeleted+          AccountDeletedTermFields+            { email = #email,+              at = d.at+            }         B.goto Deleted      B.from Confirmed do@@ -479,14 +485,23 @@             ],           target = RequiresConfirmation         },-      -- GDPR before confirmation: silent ε-edge (no event).+      -- GDPR before confirmation emits the deletion event so replay observes+      -- the state and register change.       Edge         { guard = isGdpr,           update =             USet               (#deletedAt :: IndexN "deletedAt" UserRegRegs UTCTime)               (inpGdpr #at),-          output = [],+          output =+            [ pack+                inCtorGdpr+                wireAccountDeleted+                ( OFCons+                    (proj (#email :: Index UserRegRegs Email))+                    (OFCons (inpGdpr #at) OFNil)+                )+            ],           target = Deleted         }     ]
test/Keiki/Generics/THSpec.hs view
@@ -1,4 +1,7 @@ {-# LANGUAGE TemplateHaskell #-}+-- Every splice emits a complete helper family; individual examples exercise+-- complementary subsets of those generated bindings.+{-# OPTIONS_GHC -Wno-unused-top-binds #-}  module Keiki.Generics.THSpec (spec) where 
+ test/Keiki/LawHelpers.hs view
@@ -0,0 +1,27 @@+module Keiki.LawHelpers+  ( runScript,+    emittedLog,+  )+where++import Keiki.Core++-- | Drive a command script from the initial state. Rejected commands leave+-- state unchanged and contribute an empty output batch.+runScript ::+  SymTransducer (HsPred rs ci) rs s ci co ->+  [ci] ->+  [[co]]+runScript transducer = go (initial transducer, initialRegs transducer)+  where+    go _ [] = []+    go state (command : rest) = case step transducer state command of+      Nothing -> [] : go state rest+      Just (vertex, registers, outputs) ->+        outputs : go (vertex, registers) rest++emittedLog ::+  SymTransducer (HsPred rs ci) rs s ci co ->+  [ci] ->+  [co]+emittedLog transducer = concat . runScript transducer
test/Keiki/ProfunctorSpec.hs view
@@ -12,7 +12,9 @@ import Data.Time.Clock (UTCTime (..), secondsToDiffTime) import GHC.Generics (Generic) import Keiki.Core+import Keiki.Fixtures.CounterPipeline import Keiki.Fixtures.EmailDelivery+import Keiki.LawHelpers (emittedLog, runScript) import Keiki.Profunctor import Keiki.Symbolic (isSingleValuedSym, withSymPred) import Test.Hspec@@ -213,6 +215,29 @@       case mapped of         SomeSymTransducer t ->           fireOutputsOnly t sampleEmailCmd `shouldBe` expected++  describe "forward and inversion observations" $ do+    it "dimap id id preserves a four-command stateful forward trace" $ do+      let mapped = dimapTransducer id id stageA+          script = map MsgA [1, 5, 2, 3]+      runScript mapped script `shouldBe` runScript stageA script++    it "dimap composition preserves a four-command stateful forward trace" $ do+      let f (MsgA n) = MsgA (n + 1)+          f' (MsgA n) = MsgA (n * 2)+          g (MsgB n) = MsgB (n + 3)+          g' (MsgB n) = MsgB (n * 4)+          direct = dimapTransducer (f' . f) (g . g') stageA+          nested = dimapTransducer f g (dimapTransducer f' g' stageA)+          script = map MsgA [1, 5, 2, 3]+      runScript direct script `shouldBe` runScript nested script++    it "dimap id id is not replay-equivalent" $ do+      let mapped = dimapTransducer id id stageA+          script = map MsgA [1, 5, 2, 3]+      case reconstituteEither mapped (emittedLog mapped script) of+        Left _ -> pure ()+        Right _ -> expectationFailure "dimap id id unexpectedly replayed"  -- * Hspec helpers ---------------------------------------------------------- 
test/Keiki/Render/InspectorSpec.hs view
@@ -76,7 +76,7 @@       "- **RequiresConfirmation -> Deleted**",       "  - edge index: 2",       "  - input: FulfillGDPRRequest",-      "  - output: \x03B5",+      "  - output: AccountDeleted",       "  - guard (structural): PInCtor",       "  - written slots: deletedAt",       "",@@ -130,7 +130,7 @@       "- **RequiresConfirmation -> Deleted**",       "  - edge index: 2",       "  - input: FulfillGDPRRequest",-      "  - output: \x03B5",+      "  - output: AccountDeleted",       "  - guard (structural): PInCtor",       "  - guard (pretty): FulfillGDPRRequest",       "  - written slots: deletedAt",@@ -188,7 +188,8 @@       "- **RequiresConfirmation -> Deleted**",       "  - edge index: 2",       "  - input: FulfillGDPRRequest",-      "  - output: \x03B5",+      "  - output: AccountDeleted",+      "  - output fields: AccountDeleted[field 0: email; field 1: FulfillGDPRRequest.at]",       "  - guard (structural): PInCtor",       "  - written slots: deletedAt",       "",
test/Keiki/Render/MermaidSpec.hs view
@@ -251,7 +251,7 @@       "    PotentialCustomer --> RequiresConfirmation : StartRegistration / RegistrationStarted; ConfirmationEmailSent",       "    RequiresConfirmation --> Confirmed : ConfirmAccount / AccountConfirmed",       "    RequiresConfirmation --> RequiresConfirmation : ResendConfirmation / ConfirmationResent",-      "    RequiresConfirmation --> Deleted : FulfillGDPRRequest / \x03B5",+      "    RequiresConfirmation --> Deleted : FulfillGDPRRequest / AccountDeleted",       "    Confirmed --> Deleted : FulfillGDPRRequest / AccountDeleted",       "    Deleted --> [*]"     ]@@ -273,7 +273,7 @@       "    PotentialCustomer --> RequiresConfirmation : StartRegistration / RegistrationStarted; ConfirmationEmailSent [w: registeredAt; confirmCode; email; g: PInCtor]",       "    RequiresConfirmation --> Confirmed : ConfirmAccount / AccountConfirmed [w: confirmedAt; g: PAnd PInCtor PEq]",       "    RequiresConfirmation --> RequiresConfirmation : ResendConfirmation / ConfirmationResent [w: registeredAt; confirmCode; g: PInCtor]",-      "    RequiresConfirmation --> Deleted : FulfillGDPRRequest / \x03B5 [w: deletedAt; g: PInCtor]",+      "    RequiresConfirmation --> Deleted : FulfillGDPRRequest / AccountDeleted [w: deletedAt; g: PInCtor]",       "    Confirmed --> Deleted : FulfillGDPRRequest / AccountDeleted [w: deletedAt; g: PInCtor]",       "    Deleted --> [*]"     ]@@ -294,7 +294,7 @@       "    PotentialCustomer --> RequiresConfirmation : StartRegistration / RegistrationStarted; ConfirmationEmailSent [g: StartRegistration]",       "    RequiresConfirmation --> Confirmed : ConfirmAccount / AccountConfirmed [g: (ConfirmAccount && ConfirmAccount.confirmCode == confirmCode)]",       "    RequiresConfirmation --> RequiresConfirmation : ResendConfirmation / ConfirmationResent [g: ResendConfirmation]",-      "    RequiresConfirmation --> Deleted : FulfillGDPRRequest / \x03B5 [g: FulfillGDPRRequest]",+      "    RequiresConfirmation --> Deleted : FulfillGDPRRequest / AccountDeleted [g: FulfillGDPRRequest]",       "    Confirmed --> Deleted : FulfillGDPRRequest / AccountDeleted [g: FulfillGDPRRequest]",       "    Deleted --> [*]"     ]@@ -316,7 +316,7 @@       "    PotentialCustomer --> RequiresConfirmation : StartRegistration / RegistrationStarted; ConfirmationEmailSent<br/>w: registeredAt; confirmCode; email<br/>g: PInCtor",       "    RequiresConfirmation --> Confirmed : ConfirmAccount / AccountConfirmed<br/>w: confirmedAt<br/>g: PAnd PInCtor PEq",       "    RequiresConfirmation --> RequiresConfirmation : ResendConfirmation / ConfirmationResent<br/>w: registeredAt; confirmCode<br/>g: PInCtor",-      "    RequiresConfirmation --> Deleted : FulfillGDPRRequest / \x03B5<br/>w: deletedAt<br/>g: PInCtor",+      "    RequiresConfirmation --> Deleted : FulfillGDPRRequest / AccountDeleted<br/>w: deletedAt<br/>g: PInCtor",       "    Confirmed --> Deleted : FulfillGDPRRequest / AccountDeleted<br/>w: deletedAt<br/>g: PInCtor",       "    Deleted --> [*]"     ]@@ -335,7 +335,7 @@       "    PotentialCustomer --> RequiresConfirmation : StartRegistration / RegistrationStarted; ConfirmationEmailSent [w: registeredAt; confirmCode; +1 more]",       "    RequiresConfirmation --> Confirmed : ConfirmAccount / AccountConfirmed [w: confirmedAt]",       "    RequiresConfirmation --> RequiresConfirmation : ResendConfirmation / ConfirmationResent [w: registeredAt; confirmCode]",-      "    RequiresConfirmation --> Deleted : FulfillGDPRRequest / \x03B5 [w: deletedAt]",+      "    RequiresConfirmation --> Deleted : FulfillGDPRRequest / AccountDeleted [w: deletedAt]",       "    Confirmed --> Deleted : FulfillGDPRRequest / AccountDeleted [w: deletedAt]",       "    Deleted --> [*]"     ]@@ -354,7 +354,7 @@       "    PotentialCustomer --> RequiresConfirmation : StartRegistration / RegistrationStarted; ConfirmationEmailSent [g: PInCtor]",       "    RequiresConfirmation --> Confirmed : ConfirmAccount / AccountConfirmed [g: PAnd PInCt\x2026]",       "    RequiresConfirmation --> RequiresConfirmation : ResendConfirmation / ConfirmationResent [g: PInCtor]",-      "    RequiresConfirmation --> Deleted : FulfillGDPRRequest / \x03B5 [g: PInCtor]",+      "    RequiresConfirmation --> Deleted : FulfillGDPRRequest / AccountDeleted [g: PInCtor]",       "    Confirmed --> Deleted : FulfillGDPRRequest / AccountDeleted [g: PInCtor]",       "    Deleted --> [*]"     ]@@ -446,7 +446,7 @@       "    PotentialCustomer --> RequiresConfirmation : StartRegistration / RegistrationStarted; ConfirmationEmailSent",       "    RequiresConfirmation --> Confirmed : ConfirmAccount / AccountConfirmed",       "    RequiresConfirmation --> RequiresConfirmation : ResendConfirmation / ConfirmationResent",-      "    RequiresConfirmation --> Deleted : FulfillGDPRRequest / \x03B5",+      "    RequiresConfirmation --> Deleted : FulfillGDPRRequest / AccountDeleted",       "    Confirmed --> Deleted : FulfillGDPRRequest / AccountDeleted",       "    Deleted --> [*]"     ]
+ test/Keiki/ReplayEitherSpec.hs view
@@ -0,0 +1,280 @@+module Keiki.ReplayEitherSpec (spec) where++import Data.Time (UTCTime (..), fromGregorian, secondsToDiffTime)+import Keiki.Core+import Keiki.Fixtures.UserRegistration+import Test.Hspec++t :: Integer -> UTCTime+t seconds = UTCTime (fromGregorian 2026 5 1) (secondsToDiffTime seconds)++headEvent :: UserEvent+headEvent =+  RegistrationStarted+    (RegistrationStartedData "alice@x" "Z9F4" (t 0))++tailEvent :: UserEvent+tailEvent = ConfirmationEmailSent (ConfirmationEmailSentData "alice@x")++canonicalLog :: [UserEvent]+canonicalLog =+  [ headEvent,+    tailEvent,+    ConfirmationResent (ConfirmationResentData "alice@x" "K2P7" (t 100)),+    AccountConfirmed (AccountConfirmedData "alice@x" "K2P7" (t 200)),+    AccountDeleted (AccountDeletedData "alice@x" (t 300))+  ]++type Snapshot = (Email, ConfirmationCode, UTCTime, UTCTime, UTCTime)++snapshot :: RegFile UserRegRegs -> Snapshot+snapshot regs =+  ( regs ! #email,+    regs ! #confirmCode,+    regs ! #registeredAt,+    regs ! #confirmedAt,+    regs ! #deletedAt+  )++expectedSnapshot :: Snapshot+expectedSnapshot = ("alice@x", "K2P7", t 100, t 200, t 300)++duplicateEntrance :: [Edge (HsPred UserRegRegs UserCmd) UserRegRegs UserCmd UserEvent Vertex]+duplicateEntrance = case edgesOut userRegAST PotentialCustomer of+  [edge] -> [edge, edge {target = Confirmed}]+  _ -> error "userRegAST must have exactly one PotentialCustomer edge"++ambiguousUserReg ::+  SymTransducer+    (HsPred UserRegRegs UserCmd)+    UserRegRegs+    Vertex+    UserCmd+    UserEvent+ambiguousUserReg =+  userRegAST+    { edgesOut = \source ->+        if source == PotentialCustomer+          then duplicateEntrance+          else edgesOut userRegAST source+    }++singleStepSpec :: Spec+singleStepSpec = describe "applyEventStreamingEither" $ do+  it "reports every rejected outgoing edge when no head output inverts" $+    case applyEventStreamingEither+      userReg+      (Settled PotentialCustomer)+      (initialRegs userReg)+      (AccountConfirmed (AccountConfirmedData "alice@x" "Z9F4" (t 0))) of+      Left failure ->+        failure+          `shouldBe` ReplayNoInvertingEdge+            PotentialCustomer+            [ RejectedEdgeSummary+                { rejectedEdge =+                    EdgeRef+                      { edgeSource = PotentialCustomer,+                        edgeIndex = 0+                      },+                  rejectedTarget = RequiresConfirmation,+                  rejectedGuard = False+                }+            ]+      Right _ -> expectationFailure "expected ReplayNoInvertingEdge"++  it "reports the observed event and full expected queue on a mismatch" $+    case applyEventStreamingEither+      userReg+      (InFlight RequiresConfirmation [tailEvent])+      (initialRegs userReg)+      (AccountDeleted (AccountDeletedData "alice@x" (t 999))) of+      Left failure ->+        failure+          `shouldBe` ReplayQueueMismatch+            RequiresConfirmation+            (AccountDeleted (AccountDeletedData "alice@x" (t 999)))+            [tailEvent]+      Right _ -> expectationFailure "expected ReplayQueueMismatch"++  it "reports every edge whose head output inverts ambiguously" $+    case applyEventStreamingEither+      ambiguousUserReg+      (Settled PotentialCustomer)+      (initialRegs ambiguousUserReg)+      headEvent of+      Left failure ->+        failure+          `shouldBe` ReplayAmbiguousInversions+            PotentialCustomer+            [ MatchedEdgeSummary+                { matchedEdge =+                    EdgeRef+                      { edgeSource = PotentialCustomer,+                        edgeIndex = 0+                      },+                  matchedTarget = RequiresConfirmation+                },+              MatchedEdgeSummary+                { matchedEdge =+                    EdgeRef+                      { edgeSource = PotentialCustomer,+                        edgeIndex = 1+                      },+                  matchedTarget = Confirmed+                }+            ]+      Right _ -> expectationFailure "expected ReplayAmbiguousInversions"++  it "keeps applyEventStreaming as a Nothing-returning failure wrapper" $+    case applyEventStreaming+      ambiguousUserReg+      (Settled PotentialCustomer)+      (initialRegs ambiguousUserReg)+      headEvent of+      Nothing -> pure ()+      Just _ -> expectationFailure "expected Nothing from compatibility wrapper"++spec :: Spec+spec = do+  singleStepSpec++  describe "reconstituteEither" $ do+    it "replays the canonical log to Deleted with the expected snapshot" $+      case reconstituteEither userReg canonicalLog of+        Right (finalVertex, finalRegs) ->+          (finalVertex, snapshot finalRegs)+            `shouldBe` (Deleted, expectedSnapshot)+        Left failure -> expectationFailure ("unexpected replay failure: " <> show failure)++    it "names the exact corrupted event index and queue mismatch" $+      let observed = AccountDeleted (AccountDeletedData "alice@x" (t 999))+          corrupted = headEvent : observed : drop 2 canonicalLog+       in case reconstituteEither userReg corrupted of+            Left failure ->+              failure+                `shouldBe` ReplayFailure+                  { replayFailedIndex = 1,+                    replayFailedState = InFlight RequiresConfirmation [tailEvent],+                    replayFailureReason =+                      ReplayEventFailed+                        ( ReplayQueueMismatch+                            RequiresConfirmation+                            observed+                            [tailEvent]+                        )+                  }+            Right _ -> expectationFailure "expected corrupted log to fail"++    it "reports a foreign first event at index zero" $+      let foreignEvent = AccountConfirmed (AccountConfirmedData "alice@x" "Z9F4" (t 0))+       in case reconstituteEither userReg [foreignEvent] of+            Left failure ->+              failure+                `shouldBe` ReplayFailure+                  { replayFailedIndex = 0,+                    replayFailedState = Settled PotentialCustomer,+                    replayFailureReason =+                      ReplayEventFailed+                        ( ReplayNoInvertingEdge+                            PotentialCustomer+                            [ RejectedEdgeSummary+                                { rejectedEdge =+                                    EdgeRef+                                      { edgeSource = PotentialCustomer,+                                        edgeIndex = 0+                                      },+                                  rejectedTarget = RequiresConfirmation,+                                  rejectedGuard = False+                                }+                            ]+                        )+                  }+            Right _ -> expectationFailure "expected foreign event to fail"++    it "reports a truncated multi-event chain at the input length" $+      case reconstituteEither userReg [headEvent] of+        Left failure ->+          failure+            `shouldBe` ReplayFailure+              { replayFailedIndex = 1,+                replayFailedState = InFlight RequiresConfirmation [tailEvent],+                replayFailureReason = ReplayLogTruncated [tailEvent]+              }+        Right _ -> expectationFailure "expected truncated chain to fail"++  describe "replayEvents" $ do+    it "resumes from a caller-supplied mid-chain seed" $+      case applyEventStreamingEither+        userReg+        (Settled PotentialCustomer)+        (initialRegs userReg)+        headEvent of+        Left failure -> expectationFailure ("could not build seed: " <> show failure)+        Right (wrapper, regsAfterHead) ->+          case replayEvents userReg (wrapper, regsAfterHead) [tailEvent] of+            Right (Settled RequiresConfirmation, regsAfterTail) -> do+              regsAfterTail ! #email `shouldBe` "alice@x"+              regsAfterTail ! #confirmCode `shouldBe` "Z9F4"+              regsAfterTail ! #registeredAt `shouldBe` t 0+            Right (other, _) ->+              expectationFailure ("expected settled state, got " <> show other)+            Left failure ->+              expectationFailure ("mid-chain resume failed: " <> show failure)++    it "returns a final InFlight wrapper without treating it as truncation" $+      case replayEvents+        userReg+        (Settled PotentialCustomer, initialRegs userReg)+        [headEvent] of+        Right (wrapper, _) ->+          wrapper `shouldBe` InFlight RequiresConfirmation [tailEvent]+        Left failure -> expectationFailure ("unexpected fold failure: " <> show failure)++  describe "Maybe compatibility wrappers" $+    it "return Nothing exactly where the strict variants return Left" $ do+      let observed = AccountDeleted (AccountDeletedData "alice@x" (t 999))+          corrupted = headEvent : observed : drop 2 canonicalLog+      case reconstitute userReg corrupted of+        Nothing -> pure ()+        Just _ -> expectationFailure "reconstitute accepted corrupted log"+      case applyEvents userReg (initial userReg, initialRegs userReg) corrupted of+        Nothing -> pure ()+        Just _ -> expectationFailure "applyEvents accepted corrupted log"+      case reconstituteEither userReg corrupted of+        Left _ -> pure ()+        Right _ -> expectationFailure "reconstituteEither accepted corrupted log"+      case applyEventsEither userReg (initial userReg, initialRegs userReg) corrupted of+        Left _ -> pure ()+        Right _ -> expectationFailure "applyEventsEither accepted corrupted log"++  describe "former Decider behavioral coverage" $ do+    it "replays the complete multi-event output of one forward step" $ do+      let command = StartRegistration (StartRegistrationData "alice@x" "Z9F4" (t 0))+      case stepEither userReg (initial userReg, initialRegs userReg) command of+        Left failure -> expectationFailure ("forward step failed: " <> show failure)+        Right (_, _, emitted) -> do+          emitted `shouldBe` [headEvent, tailEvent]+          case applyEventsEither userReg (initial userReg, initialRegs userReg) emitted of+            Right (vertex, _) -> vertex `shouldBe` RequiresConfirmation+            Left failure -> expectationFailure ("multi-event replay failed: " <> show failure)++    it "preserves the durable pre-confirmation deletion path" $ do+      let startCommand = StartRegistration (StartRegistrationData "bob@x" "S0E1" (t 0))+          deleteCommand = FulfillGDPRRequest (FulfillGDPRRequestData (t 999))+      case stepEither userReg (initial userReg, initialRegs userReg) startCommand of+        Left failure -> expectationFailure ("registration failed: " <> show failure)+        Right (_, _, startEvents) ->+          case applyEventsEither userReg (initial userReg, initialRegs userReg) startEvents of+            Left failure -> expectationFailure ("registration replay failed: " <> show failure)+            Right preDeletion@(RequiresConfirmation, _) ->+              case stepEither userReg preDeletion deleteCommand of+                Left failure -> expectationFailure ("deletion failed: " <> show failure)+                Right (_, _, deletionEvents) -> do+                  deletionEvents+                    `shouldBe` [AccountDeleted (AccountDeletedData "bob@x" (t 999))]+                  case applyEventsEither userReg preDeletion deletionEvents of+                    Right (vertex, _) -> vertex `shouldBe` Deleted+                    Left failure -> expectationFailure ("deletion replay failed: " <> show failure)+            Right (other, _) ->+              expectationFailure ("expected RequiresConfirmation, got " <> show other)
+ test/Keiki/RoundTrip.hs view
@@ -0,0 +1,332 @@+{-# LANGUAGE BlockArguments #-}+{-# LANGUAGE GADTs #-}++-- | Reusable decide/replay round-trip properties for keiki test fixtures.+module Keiki.RoundTrip+  ( RoundTripFixture (..),+    TamperExpectation (..),+    TamperCase (..),+    roundTripSpec,+    roundTripSpecUnchecked,+    teethSpec,+    genUTCTime,+    genShortText,+    genFromPool,+  )+where++import Data.Text (Text)+import Data.Text qualified as Text+import Data.Time (UTCTime)+import Data.Time.Clock.POSIX (posixSecondsToUTCTime)+import Keiki.Core+  ( HsPred,+    RegFile,+    ReplayFailure,+    SymTransducer (..),+    applyEventsEither,+    defaultValidationOptions,+    reconstituteEither,+    step,+    validateTransducer,+  )+import Test.Hspec (Spec, describe, it, shouldBe, shouldNotBe)+import Test.QuickCheck+  ( Gen,+    Property,+    checkCoverage,+    chooseInt,+    chooseInteger,+    counterexample,+    cover,+    elements,+    expectFailure,+    forAllShrinkShow,+    frequency,+    property,+    shrinkList,+    sized,+    vectorOf,+    (===),+  )++-- | What a fixture-specific log mutation promises.+data TamperExpectation = MustFailReplay | MustNotSilentlyMatch++-- | A semantically justified mutation of an event log.+data TamperCase co = TamperCase+  { tcName :: String,+    tcMutate :: [co] -> Maybe [co],+    tcExpect :: TamperExpectation+  }++-- | Everything the generic properties need for one aggregate.+data RoundTripFixture where+  RoundTripFixture ::+    (Bounded s, Enum s, Eq s, Ord s, Show s, Show ci, Eq co, Show co) =>+    { rtName :: String,+      rtTransducer :: SymTransducer (HsPred rs ci) rs s ci co,+      rtGenCommand :: s -> RegFile rs -> Gen ci,+      rtObserve :: s -> RegFile rs -> Text,+      rtTamperCases :: [TamperCase co]+    } ->+    RoundTripFixture++data StepMark = Accepted | Rejected | Epsilon++data TraceStep s ci co = TraceStep+  { tsCommand :: ci,+    tsMark :: StepMark,+    tsEvents :: [co],+    tsState :: s,+    tsObservation :: Text+  }++data ForwardRun s ci co = ForwardRun+  { frTrace :: [TraceStep s ci co],+    frEvents :: [co],+    frFinalState :: s,+    frFinalObservation :: Text+  }++roundTripSpec :: RoundTripFixture -> Spec+roundTripSpec (RoundTripFixture name transducer genCommand observe tamperCases) =+  describe name do+    it "passes validateTransducer with defaultValidationOptions" $+      validateTransducer defaultValidationOptions transducer `shouldBe` []+    it "P1: whole-log replay reproduces the forward state" $+      wholeLogProperty transducer genCommand observe+    it "P2: chunked replay agrees at every command boundary" $+      chunkedProperty transducer genCommand observe+    mapM_ (tamperSpec transducer genCommand observe) tamperCases++-- | Run the replay laws without forcing validation. This is reserved for a+-- fixture whose validation itself is the subject of a later correctness plan.+roundTripSpecUnchecked :: RoundTripFixture -> Spec+roundTripSpecUnchecked (RoundTripFixture name transducer genCommand observe tamperCases) =+  describe name do+    it "validation is explicitly deferred to the composition-alignment plans" $+      (pure () :: IO ())+    it "P1: whole-log replay reproduces the forward state" $+      wholeLogProperty transducer genCommand observe+    it "P2: chunked replay agrees at every command boundary" $+      chunkedProperty transducer genCommand observe+    mapM_ (tamperSpec transducer genCommand observe) tamperCases++teethSpec :: RoundTripFixture -> Spec+teethSpec (RoundTripFixture name transducer genCommand observe _tamperCases) =+  describe (name <> " (teeth)") do+    it "default validation rejects the fixture" $+      validateTransducer defaultValidationOptions transducer `shouldNotBe` []+    it "P1: whole-log replay detects the defect" $+      expectFailure (wholeLogProperty transducer genCommand observe)+    it "P2: chunked replay detects the defect" $+      expectFailure (chunkedProperty transducer genCommand observe)++wholeLogProperty ::+  (Eq s, Show s, Show ci, Eq co, Show co) =>+  SymTransducer (HsPred rs ci) rs s ci co ->+  (s -> RegFile rs -> Gen ci) ->+  (s -> RegFile rs -> Text) ->+  Property+wholeLogProperty transducer genCommand observe =+  forAllCommands transducer genCommand $ \commands ->+    let run = forwardRun transducer observe commands+        result = reconstituteEither transducer run.frEvents+        context = renderRun run <> "\nreplay: " <> renderReplay observe result+     in counterexample context case result of+          Left _ -> property False+          Right (replayState, replayRegs) ->+            (replayState, observe replayState replayRegs)+              === (run.frFinalState, run.frFinalObservation)++chunkedProperty ::+  (Eq s, Show s, Show ci, Eq co, Show co) =>+  SymTransducer (HsPred rs ci) rs s ci co ->+  (s -> RegFile rs -> Gen ci) ->+  (s -> RegFile rs -> Text) ->+  Property+chunkedProperty transducer genCommand observe =+  forAllCommands transducer genCommand $ \commands ->+    let run = forwardRun transducer observe commands+        result = replayChunks (transducer.initial, transducer.initialRegs) run.frTrace+        context = renderRun run <> "\nchunked replay: " <> renderChunkResult result+     in counterexample context case result of+          Left _ -> property False+          Right (replayState, replayRegs) ->+            (replayState, observe replayState replayRegs)+              === (run.frFinalState, run.frFinalObservation)+  where+    replayChunks seed [] = Right seed+    replayChunks seed (traceStep : rest) = case traceStep.tsMark of+      Rejected -> replayChunks seed rest+      Accepted -> advance seed traceStep+      Epsilon -> advance seed traceStep+      where+        advance current stepResult = do+          next@(actualState, actualRegs) <-+            case applyEventsEither transducer current stepResult.tsEvents of+              Left replayFailure -> Left ("replay failure: " <> show replayFailure)+              Right replayed -> Right replayed+          if (actualState, observe actualState actualRegs)+            == (stepResult.tsState, stepResult.tsObservation)+            then replayChunks next rest+            else+              Left+                ( "boundary mismatch after "+                    <> show stepResult.tsCommand+                    <> ": expected "+                    <> renderState stepResult.tsState stepResult.tsObservation+                    <> ", got "+                    <> renderState actualState (observe actualState actualRegs)+                )++tamperSpec ::+  (Eq s, Show s, Show ci, Eq co, Show co) =>+  SymTransducer (HsPred rs ci) rs s ci co ->+  (s -> RegFile rs -> Gen ci) ->+  (s -> RegFile rs -> Text) ->+  TamperCase co ->+  Spec+tamperSpec transducer genCommand observe tamperCase =+  it ("tamper: " <> tamperCase.tcName) $+    checkCoverage $+      forAllCommands transducer genCommand $ \commands ->+        let run = forwardRun transducer observe commands+         in case tamperCase.tcMutate run.frEvents of+              Nothing ->+                cover 30 False "mutation applies" (property True)+              Just mutatedEvents ->+                let replay = reconstituteEither transducer mutatedEvents+                    context =+                      renderRun run+                        <> "\nmutated log:\n  "+                        <> show mutatedEvents+                        <> "\nmutated replay: "+                        <> renderReplay observe replay+                    assertion = case tamperCase.tcExpect of+                      MustFailReplay -> case replay of+                        Left _ -> property True+                        Right _ -> property False+                      MustNotSilentlyMatch -> case replay of+                        Left _ -> property True+                        Right (replayState, replayRegs) ->+                          property $+                            (replayState, observe replayState replayRegs)+                              /= (run.frFinalState, run.frFinalObservation)+                 in cover 30 True "mutation applies" (counterexample context assertion)++forAllCommands ::+  (Show ci) =>+  SymTransducer (HsPred rs ci) rs s ci co ->+  (s -> RegFile rs -> Gen ci) ->+  ([ci] -> Property) ->+  Property+forAllCommands transducer genCommand =+  forAllShrinkShow+    (genCommands transducer genCommand)+    (shrinkList (const []))+    renderCommands++genCommands ::+  SymTransducer (HsPred rs ci) rs s ci co ->+  (s -> RegFile rs -> Gen ci) ->+  Gen [ci]+genCommands transducer genCommand = sized $ \size -> do+  count <- chooseInt (1, max 1 (min 15 size))+  go count transducer.initial transducer.initialRegs+  where+    go 0 _ _ = pure []+    go remaining state regs = do+      command <- genCommand state regs+      let next = case step transducer (state, regs) command of+            Nothing -> (state, regs)+            Just (state', regs', _) -> (state', regs')+      rest <- go (remaining - 1) (fst next) (snd next)+      pure (command : rest)++forwardRun ::+  SymTransducer (HsPred rs ci) rs s ci co ->+  (s -> RegFile rs -> Text) ->+  [ci] ->+  ForwardRun s ci co+forwardRun transducer observe = go transducer.initial transducer.initialRegs [] []+  where+    go state regs traceRev eventsRev [] =+      ForwardRun+        { frTrace = reverse traceRev,+          frEvents = concat (reverse eventsRev),+          frFinalState = state,+          frFinalObservation = observe state regs+        }+    go state regs traceRev eventsRev (command : commands) =+      case step transducer (state, regs) command of+        Nothing ->+          go+            state+            regs+            (TraceStep command Rejected [] state (observe state regs) : traceRev)+            eventsRev+            commands+        Just (nextState, nextRegs, events) ->+          let mark = if null events then Epsilon else Accepted+           in go+                nextState+                nextRegs+                (TraceStep command mark events nextState (observe nextState nextRegs) : traceRev)+                (events : eventsRev)+                commands++renderCommands :: (Show ci) => [ci] -> String+renderCommands commands =+  "commands:\n"+    <> unlines+      [ "  " <> show index <> ". " <> show command+      | (index, command) <- zip [(1 :: Int) ..] commands+      ]++renderRun :: (Show s, Show ci, Show co) => ForwardRun s ci co -> String+renderRun run =+  "commands (* accepted, - rejected, epsilon accepted-with-zero-output):\n"+    <> unlines+      [ "  " <> show index <> ". " <> renderMark traceStep.tsMark <> " " <> show traceStep.tsCommand+      | (index, traceStep) <- zip [(1 :: Int) ..] run.frTrace+      ]+    <> "event log:\n  "+    <> show run.frEvents+    <> "\nforward final: "+    <> renderState run.frFinalState run.frFinalObservation++renderMark :: StepMark -> String+renderMark Accepted = "*"+renderMark Rejected = "-"+renderMark Epsilon = "epsilon"++renderState :: (Show s) => s -> Text -> String+renderState state observation = show state <> " | " <> Text.unpack observation++renderReplay :: (Show s, Show co) => (s -> RegFile rs -> Text) -> Either (ReplayFailure s co) (s, RegFile rs) -> String+renderReplay observe = \case+  Left failure -> "Left " <> show failure+  Right (state, regs) -> "Right (" <> renderState state (observe state regs) <> ")"++renderChunkResult :: (Show s) => Either String (s, RegFile rs) -> String+renderChunkResult = \case+  Left message -> "Left (" <> message <> ")"+  Right (state, _) -> "Right (" <> show state <> ")"++-- | Whole-second timestamps keep generated values and counterexamples compact.+genUTCTime :: Gen UTCTime+genUTCTime =+  posixSecondsToUTCTime . fromInteger <$> chooseInteger (0, 2_000_000_000)++-- | A compact text generator suitable for domain identifiers and payloads.+genShortText :: Gen Text+genShortText = do+  length' <- chooseInt (1, 10)+  Text.pack <$> vectorOf length' (elements ['a' .. 'z'])++-- | Prefer a collision-friendly pool while retaining arbitrary short values.+genFromPool :: [Text] -> Gen Text+genFromPool [] = genShortText+genFromPool pool = frequency [(4, elements pool), (1, genShortText)]
+ test/Keiki/RoundTripSpec.hs view
@@ -0,0 +1,371 @@+{-# LANGUAGE BlockArguments #-}++module Keiki.RoundTripSpec (spec) where++import Data.Text (Text)+import Data.Text qualified as Text+import Keiki.Core+import Keiki.Fixtures.BrokenTailCoverage qualified as Broken+import Keiki.Fixtures.CounterPipeline qualified as Counter+import Keiki.Fixtures.EmailDelivery qualified as Email+import Keiki.Fixtures.RegisterEmission qualified as Register+import Keiki.Fixtures.SplitCoverage qualified as Split+import Keiki.Fixtures.UserRegistration qualified as User+import Keiki.RoundTrip+import Test.Hspec (Spec, describe, it, shouldSatisfy)+import Test.QuickCheck (Gen, elements, frequency)++spec :: Spec+spec = do+  mapM_ roundTripSpec allFixtures+  teethSpec stateChangingEpsilonFixture+  teethSpec brokenTailCoverageFixture+  teethSpec counterPipelineFixture+  describe "validator/teeth agreement" do+    it "classifies the silent transition as StateChangingEpsilon" $+      validateTransducer defaultValidationOptions stateChangingEpsilon+        `shouldSatisfy` any isStateChangingEpsilon+    it "classifies BrokenTailCoverage as HeadUnrecoverable" $+      validateTransducer defaultValidationOptions Broken.brokenTailCoverage+        `shouldSatisfy` any isHeadUnrecoverable++allFixtures :: [RoundTripFixture]+allFixtures =+  [ emailDeliveryFixture,+    userRegistrationFixture,+    registerEmissionFixture,+    splitCoverageFixture+  ]++emailDeliveryFixture :: RoundTripFixture+emailDeliveryFixture =+  RoundTripFixture+    { rtName = "EmailDelivery",+      rtTransducer = Email.emailDelivery,+      rtGenCommand = \_ _ -> Email.SendEmail <$> genSendEmailData,+      rtObserve = observeEmail,+      rtTamperCases = emailTamperCases+    }++genSendEmailData :: Gen Email.SendEmailData+genSendEmailData =+  Email.SendEmailData+    <$> genShortText+    <*> genShortText+    <*> genUTCTime++observeEmail :: Email.EmailVertex -> RegFile Email.EmailRegs -> Text+observeEmail Email.EmailPending _ = "(no slots)"+observeEmail Email.EmailSentVertex regs =+  Text.pack $+    "recipient="+      <> show (regs ! #emailRecipient)+      <> " subject="+      <> show (regs ! #emailSubject)+      <> " sentAt="+      <> show (regs ! #emailSentAt)++emailTamperCases :: [TamperCase Email.EmailEvent]+emailTamperCases =+  [ TamperCase+      { tcName = "drop only event",+        tcMutate = \case+          [_] -> Just []+          _ -> Nothing,+        tcExpect = MustNotSilentlyMatch+      },+    TamperCase+      { tcName = "duplicate event",+        tcMutate = \case+          [event] -> Just [event, event]+          _ -> Nothing,+        tcExpect = MustFailReplay+      }+  ]++userRegistrationFixture :: RoundTripFixture+userRegistrationFixture =+  RoundTripFixture+    { rtName = "UserRegistration",+      rtTransducer = User.userReg,+      rtGenCommand = genUserCommand,+      rtObserve = observeUser,+      rtTamperCases = userTamperCases+    }++genUserCommand :: User.Vertex -> RegFile User.UserRegRegs -> Gen User.UserCmd+genUserCommand User.PotentialCustomer _ =+  frequency+    [ (8, User.StartRegistration <$> genStartRegistrationData),+      (1, User.ConfirmAccount <$> genConfirmAccountData "wrong"),+      (1, User.FulfillGDPRRequest . User.FulfillGDPRRequestData <$> genUTCTime)+    ]+genUserCommand User.RequiresConfirmation regs =+  frequency+    [ (5, User.ConfirmAccount <$> genConfirmAccountData (regs ! #confirmCode)),+      (2, User.ConfirmAccount <$> genConfirmAccountData "wrong"),+      (4, User.ResendConfirmation <$> genResendConfirmationData),+      (2, User.FulfillGDPRRequest . User.FulfillGDPRRequestData <$> genUTCTime)+    ]+genUserCommand User.Confirmed _ =+  frequency+    [ (8, User.FulfillGDPRRequest . User.FulfillGDPRRequestData <$> genUTCTime),+      (1, User.StartRegistration <$> genStartRegistrationData)+    ]+genUserCommand User.Deleted _ = arbitraryUserCommand++genStartRegistrationData :: Gen User.StartRegistrationData+genStartRegistrationData =+  User.StartRegistrationData+    <$> genShortText+    <*> genFromPool ["alpha", "beta", "gamma"]+    <*> genUTCTime++genConfirmAccountData :: Text -> Gen User.ConfirmAccountData+genConfirmAccountData code = User.ConfirmAccountData code <$> genUTCTime++genResendConfirmationData :: Gen User.ResendConfirmationData+genResendConfirmationData =+  User.ResendConfirmationData+    <$> genFromPool ["alpha", "beta", "gamma"]+    <*> genUTCTime++arbitraryUserCommand :: Gen User.UserCmd+arbitraryUserCommand =+  elements [User.FulfillGDPRRequest (User.FulfillGDPRRequestData epoch)]+  where+    epoch = read "1970-01-01 00:00:00 UTC"++observeUser :: User.Vertex -> RegFile User.UserRegRegs -> Text+observeUser User.PotentialCustomer _ = "(no slots)"+observeUser User.RequiresConfirmation regs =+  Text.pack $+    "email="+      <> show (regs ! #email)+      <> " confirmCode="+      <> show (regs ! #confirmCode)+      <> " registeredAt="+      <> show (regs ! #registeredAt)+observeUser User.Confirmed regs =+  observeUser User.RequiresConfirmation regs+    <> Text.pack (" confirmedAt=" <> show (regs ! #confirmedAt))+observeUser User.Deleted regs =+  Text.pack $+    "email="+      <> show (regs ! #email)+      <> " confirmCode="+      <> show (regs ! #confirmCode)+      <> " registeredAt="+      <> show (regs ! #registeredAt)+      <> " deletedAt="+      <> show (regs ! #deletedAt)++userTamperCases :: [TamperCase User.UserEvent]+userTamperCases =+  [ TamperCase+      { tcName = "drop chain tail",+        tcMutate = removeFirstConfirmationEmail,+        tcExpect = MustFailReplay+      },+    TamperCase+      { tcName = "swap chain events",+        tcMutate = swapRegistrationChain,+        tcExpect = MustFailReplay+      },+    TamperCase+      { tcName = "truncate mid-chain",+        tcMutate = truncateAfterRegistrationStarted,+        tcExpect = MustFailReplay+      },+    TamperCase+      { tcName = "duplicate chain head",+        tcMutate = duplicateRegistrationStarted,+        tcExpect = MustFailReplay+      },+    TamperCase+      { tcName = "foreign splice",+        tcMutate = \events -> Just (foreignAccountConfirmed : events),+        tcExpect = MustFailReplay+      }+  ]++removeFirstConfirmationEmail :: [User.UserEvent] -> Maybe [User.UserEvent]+removeFirstConfirmationEmail = \case+  [] -> Nothing+  User.ConfirmationEmailSent _ : rest -> Just rest+  event : rest -> (event :) <$> removeFirstConfirmationEmail rest++swapRegistrationChain :: [User.UserEvent] -> Maybe [User.UserEvent]+swapRegistrationChain = \case+  first@(User.RegistrationStarted _) : second@(User.ConfirmationEmailSent _) : rest ->+    Just (second : first : rest)+  event : rest -> (event :) <$> swapRegistrationChain rest+  [] -> Nothing++truncateAfterRegistrationStarted :: [User.UserEvent] -> Maybe [User.UserEvent]+truncateAfterRegistrationStarted = go []+  where+    go _ [] = Nothing+    go prefix (event@(User.RegistrationStarted _) : _) =+      Just (reverse (event : prefix))+    go prefix (event : rest) = go (event : prefix) rest++duplicateRegistrationStarted :: [User.UserEvent] -> Maybe [User.UserEvent]+duplicateRegistrationStarted = \case+  first@(User.RegistrationStarted _) : second@(User.ConfirmationEmailSent _) : rest ->+    Just (first : second : first : rest)+  event : rest -> (event :) <$> duplicateRegistrationStarted rest+  [] -> Nothing++foreignAccountConfirmed :: User.UserEvent+foreignAccountConfirmed =+  User.AccountConfirmed+    User.AccountConfirmedData+      { email = "foreign@example.test",+        confirmCode = "foreign-code",+        at = read "1970-01-01 00:00:00 UTC"+      }++brokenTailCoverageFixture :: RoundTripFixture+brokenTailCoverageFixture =+  RoundTripFixture+    { rtName = "BrokenTailCoverage",+      rtTransducer = Broken.brokenTailCoverage,+      rtGenCommand = \_ _ ->+        Broken.Provision+          <$> (Broken.ProvisionData <$> genShortText <*> elements [0 .. 20]),+      rtObserve = observeBrokenTailCoverage,+      rtTamperCases = []+    }++observeBrokenTailCoverage :: Broken.BrokenVertex -> RegFile Broken.BrokenRegs -> Text+observeBrokenTailCoverage Broken.BtcIdle _ = "(no slots)"+observeBrokenTailCoverage Broken.BtcProvisioned regs =+  Text.pack $+    "owner="+      <> show (regs ! #owner)+      <> " quota="+      <> show (regs ! #quota)++registerEmissionFixture :: RoundTripFixture+registerEmissionFixture =+  RoundTripFixture+    { rtName = "RegisterEmission",+      rtTransducer = Register.registerEmission,+      rtGenCommand = genRegisterCommand,+      rtObserve = observeRegister,+      rtTamperCases =+        [ TamperCase+            { tcName = "truncate mid-chain",+              tcMutate = truncateAfterClosed,+              tcExpect = MustFailReplay+            }+        ]+    }++genRegisterCommand :: Register.RegisterVertex -> RegFile Register.RegisterEmissionRegs -> Gen Register.RegisterCmd+genRegisterCommand Register.Fresh _ =+  frequency+    [ (8, Register.Open <$> genShortText),+      (1, Register.Add <$> elements [-10 .. 10]),+      (1, pure Register.Close)+    ]+genRegisterCommand Register.Active _ =+  frequency+    [ (5, Register.Add <$> elements [-10 .. 10]),+      (5, pure Register.Close),+      (1, Register.Open <$> genShortText)+    ]+genRegisterCommand Register.Finished _ = pure Register.Close++observeRegister :: Register.RegisterVertex -> RegFile Register.RegisterEmissionRegs -> Text+observeRegister Register.Fresh _ = "(initial)"+observeRegister state regs =+  Text.pack $+    show state+      <> " owner="+      <> show (regs ! #owner)+      <> " total="+      <> show (regs ! #total)++truncateAfterClosed :: [Register.RegisterEvent] -> Maybe [Register.RegisterEvent]+truncateAfterClosed = go []+  where+    go _ [] = Nothing+    go prefix (event@(Register.Closed _) : _) = Just (reverse (event : prefix))+    go prefix (event : rest) = go (event : prefix) rest++splitCoverageFixture :: RoundTripFixture+splitCoverageFixture =+  RoundTripFixture+    { rtName = "SplitCoverage.fixed",+      rtTransducer = Split.splitCoverageFixed,+      rtGenCommand = \_ _ ->+        Split.Begin+          <$> elements [-10 .. 10]+          <*> elements [-10 .. 10]+          <*> elements [-10 .. 10],+      rtObserve = \state _ -> Text.pack (show state),+      rtTamperCases =+        [ TamperCase+            { tcName = "truncate mid-chain",+              tcMutate = \case+                Split.OutABC a b c : _ -> Just [Split.OutABC a b c]+                _ -> Nothing,+              tcExpect = MustFailReplay+            }+        ]+    }++counterPipelineFixture :: RoundTripFixture+counterPipelineFixture =+  RoundTripFixture+    { rtName = "CounterPipeline.stageA (derived-only output)",+      rtTransducer = Counter.stageA,+      rtGenCommand = \_ _ -> Counter.MsgA <$> elements [0 .. 20],+      rtObserve = \Counter.StageVertex regs ->+        Text.pack ("regA=" <> show (regs ! #regA)),+      rtTamperCases = []+    }++isStateChangingEpsilon :: TransducerValidationWarning s -> Bool+isStateChangingEpsilon StateChangingEpsilon {} = True+isStateChangingEpsilon _ = False++isHeadUnrecoverable :: TransducerValidationWarning s -> Bool+isHeadUnrecoverable HeadUnrecoverable {} = True+isHeadUnrecoverable _ = False++data EpsilonVertex = EpsilonStart | EpsilonDone+  deriving (Eq, Ord, Show, Enum, Bounded)++data EpsilonCommand = EpsilonAdvance+  deriving (Eq, Show)++stateChangingEpsilonFixture :: RoundTripFixture+stateChangingEpsilonFixture =+  RoundTripFixture+    { rtName = "StateChangingEpsilon",+      rtTransducer = stateChangingEpsilon,+      rtGenCommand = \_ _ -> pure EpsilonAdvance,+      rtObserve = \state _ -> Text.pack (show state),+      rtTamperCases = []+    }++stateChangingEpsilon :: SymTransducer (HsPred '[] EpsilonCommand) '[] EpsilonVertex EpsilonCommand ()+stateChangingEpsilon =+  SymTransducer+    { initial = EpsilonStart,+      initialRegs = RNil,+      isFinal = (== EpsilonDone),+      edgesOut = \case+        EpsilonStart ->+          [ Edge+              { guard = PTop,+                update = UKeep,+                output = [],+                target = EpsilonDone+              }+          ]+        EpsilonDone -> []+    }
test/Keiki/ShapeSpec.hs view
@@ -1,27 +1,61 @@ -- | EP-36 M1: golden-value tests for 'Keiki.Shape'. ----- The expected strings are pinned for GHC 9.12.* (the current sole entry--- in @tested-with@). If a future GHC moves @Int@ out of @GHC.Types@ or--- renames @GHC.Internal.Maybe@, these tests catch the drift; EP-36 §8--- documents the procedure (audit, mitigate via 'CanonicalTypeName'--- overrides, decide whether to ship a migration). See EP-36 §3 R4 (cross---- version stability) and §5 P5 (the hash uses only stable accessors).+-- The raw 'renderStableTypeRep' expectations remain pinned for GHC 9.12.*.+-- Shape canonicalization uses explicit built-in names instead, so GHC-internal+-- module moves no longer change snapshot hashes. module Keiki.ShapeSpec (spec) where +import Data.Int (Int16, Int32, Int64, Int8) import Data.Kind (Type) import Data.Proxy (Proxy (..))+import Data.Text (Text) import Data.Text qualified as T+import Data.Time.Calendar (Day) import Data.Time.Clock (UTCTime)+import Data.Word (Word16, Word32, Word64, Word8) import GHC.TypeLits (Symbol) import Keiki.Shape-  ( regFileShapeCanonical,+  ( CanonicalTypeName (..),+    regFileShapeCanonical,     regFileShapeHash,     renderStableTypeRep,     sha256Hex,   )-import Test.Hspec (Spec, describe, it, shouldBe)+import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy) import Type.Reflection (someTypeRep) +data ApplicationType++instance CanonicalTypeName ApplicationType where+  canonicalTypeName _ = T.pack "ApplicationType-v1"++type BuiltInSlots =+  '[ '("unit", ()),+     '("bool", Bool),+     '("char", Char),+     '("int", Int),+     '("int8", Int8),+     '("int16", Int16),+     '("int32", Int32),+     '("int64", Int64),+     '("integer", Integer),+     '("word", Word),+     '("word8", Word8),+     '("word16", Word16),+     '("word32", Word32),+     '("word64", Word64),+     '("double", Double),+     '("float", Float),+     '("text", Text),+     '("utcTime", UTCTime),+     '("day", Day),+     '("maybe", Maybe Int),+     '("list", [Text]),+     '("either", Either Int Text),+     '("pair", (Int, Text)),+     '("triple", (Int, Text, Bool))+   ]+ spec :: Spec spec = do   describe "renderStableTypeRep" $ do@@ -44,8 +78,18 @@      it "concatenates one slot in the documented R3 form" $       regFileShapeCanonical (Proxy @('[ '("retryCount", Int)] :: [(Symbol, Type)]))-        `shouldBe` T.pack "retryCount:GHC.Types.Int;regfile:0"+        `shouldBe` T.pack "retryCount:Int;regfile:0" +    it "keeps GHC-internal module paths out of every built-in name" $ do+      let canonical = regFileShapeCanonical (Proxy @BuiltInSlots)+      canonical `shouldSatisfy` (not . T.isInfixOf (T.pack "GHC.Internal"))+      canonical `shouldSatisfy` (not . T.isInfixOf (T.pack "GHC.Types"))++    it "propagates an application override through containers" $+      regFileShapeCanonical+        (Proxy @('[ '("application", Maybe ApplicationType)] :: [(Symbol, Type)]))+        `shouldBe` T.pack "application:Maybe(ApplicationType-v1);regfile:0"+   describe "regFileShapeHash" $ do     it "produces the pinned SHA-256 of \"regfile:0\" for the empty list" $       regFileShapeHash (Proxy @('[] :: [(Symbol, Type)]))@@ -53,12 +97,12 @@      it "produces the pinned hash for a one-slot list (retryCount :: Int)" $       regFileShapeHash (Proxy @('[ '("retryCount", Int)] :: [(Symbol, Type)]))-        `shouldBe` T.pack "e2c8839d9ae8e89baebbc1adf6dfd5a35608712d9bf994c7cef4ea774e739700"+        `shouldBe` T.pack "de03289268ae222f84d8a1b9af8f4f78bc9d23a747c97c12f4974e2504485978"      it "differs when slot order is reversed (P10: slot order is identity)" $       regFileShapeHash         (Proxy @('[ '("retryCount", Int), '("cooldownUntil", UTCTime)] :: [(Symbol, Type)]))-        `shouldBe` T.pack "944d775449408b12b78b2a41770af207bae37d0a833c046310eb6ff3902ea44f"+        `shouldBe` T.pack "22a08cf2b847545bf0ce24f505de379ee49c2edb8c2236b6f6bcfadba984b1ea"      it "matches its sha256Hex-of-canonical definition" $ do       let p = Proxy @('[ '("retryCount", Int), '("cooldownUntil", UTCTime)] :: [(Symbol, Type)])
test/Keiki/StrongSpec.hs view
@@ -24,11 +24,14 @@ module Keiki.StrongSpec (spec) where  import Control.Category qualified as Cat+import Control.Exception (evaluate) import Data.Profunctor (Strong (..)) import Data.Time.Calendar (fromGregorian) import Data.Time.Clock (UTCTime (..), secondsToDiffTime) import Keiki.Core+import Keiki.Fixtures.CounterPipeline import Keiki.Fixtures.EmailDelivery+import Keiki.LawHelpers (emittedLog, runScript) import Keiki.Profunctor import Keiki.Symbolic (isSingleValuedSym, withSymPred) import Test.Hspec@@ -75,10 +78,6 @@         SomeSymTransducer t ->           omega t (initial t) (initialRegs t) (sampleSendEmail, requestId)             `shouldBe` [(sampleEmailEvent, requestId)]-        SomeSymIdentity ->-          expectationFailure-            "first' (someSymTransducer emailDelivery) unexpectedly returned \-            \the identity sentinel"      it "preserves Cat.id on the sentinel: first' Cat.id == Cat.id" $ do       let lifted :: SomeSymTransducer (Int, Bool) (Int, Bool)@@ -97,10 +96,6 @@         SomeSymTransducer t ->           omega t (initial t) (initialRegs t) (requestId, sampleSendEmail)             `shouldBe` [(requestId, sampleEmailEvent)]-        SomeSymIdentity ->-          expectationFailure-            "second' (someSymTransducer emailDelivery) unexpectedly returned \-            \the identity sentinel"      it "preserves Cat.id on the sentinel: second' Cat.id == Cat.id" $ do       let lifted :: SomeSymTransducer (Bool, Int) (Bool, Int)@@ -116,15 +111,45 @@              SomeSymTransducer (EmailCmd, RequestId) (EmailEvent, RequestId) of         SomeSymTransducer t ->           isSingleValuedSym (withSymPred t) `shouldBe` True-        SomeSymIdentity ->-          expectationFailure-            "first' on a non-identity wrapper returned the identity sentinel"      it "single-valuedness is preserved across second'" $       case second' someEmail ::              SomeSymTransducer (RequestId, EmailCmd) (RequestId, EmailEvent) of         SomeSymTransducer t ->           isSingleValuedSym (withSymPred t) `shouldBe` True-        SomeSymIdentity ->-          expectationFailure-            "second' on a non-identity wrapper returned the identity sentinel"++  describe "forward and inversion observations" $ do+    it "first' threads every value through a four-command stateful trace" $ do+      let lifted =+            first' (someSymTransducer stageA) ::+              SomeSymTransducer (MsgA, RequestId) (MsgB, RequestId)+          script = zip (map MsgA [1, 5, 2, 3]) (map RequestId [10, 11, 12, 13])+      case lifted of+        SomeSymTransducer transducer ->+          runScript transducer script+            `shouldBe` [ [(MsgB 2, RequestId 10)],+                         [(MsgB 10, RequestId 11)],+                         [(MsgB 4, RequestId 12)],+                         [(MsgB 6, RequestId 13)]+                       ]++    it "first' is not replay-equivalent because its wire matcher is poisoned" $ do+      let lifted =+            first' (someSymTransducer stageA) ::+              SomeSymTransducer (MsgA, RequestId) (MsgB, RequestId)+          script = zip (map MsgA [1, 5, 2, 3]) (map RequestId [10, 11, 12, 13])+      case lifted of+        SomeSymTransducer transducer ->+          case reconstituteEither transducer (emittedLog transducer script) of+            Left _ -> pure ()+            Right _ -> expectationFailure "first' unexpectedly replayed"++    it "first' composition fails loudly at its poisoned boundary" $ do+      let firstA =+            first' (someSymTransducer stageA) ::+              SomeSymTransducer (MsgA, Bool) (MsgB, Bool)+          firstB =+            first' (someSymTransducer stageB) ::+              SomeSymTransducer (MsgB, Bool) (MsgC, Bool)+      evaluate (firstB Cat.. firstA)+        `shouldThrow` (\e -> pceSide e == "upstream output")
test/Keiki/SymbolicSpec.hs view
@@ -7,6 +7,7 @@ import Data.SBV qualified as SBV import Data.Text (Text) import Data.Time (UTCTime)+import Data.Time.Clock.POSIX (posixSecondsToUTCTime) import Data.Typeable (Typeable) import Data.Word (Word16, Word32, Word64, Word8) import Keiki.Symbolic@@ -41,6 +42,77 @@ amountIdx :: Index AmountRegs Word64 amountIdx = ZIdx +-- | A small exact-bit-vector fixture whose overlapping guards are visible only+-- when Word8 arithmetic wraps as it does at runtime.+type ByteRegs = '[ '("byte", Word8)]++byteIdx :: Index ByteRegs Word8+byteIdx = ZIdx++byteWrapGuard, byteHighGuard :: HsPred ByteRegs AmtCmd+byteWrapGuard =+  PCmp+    CmpLe+    (TArith OpAdd (proj byteIdx) (TLit 6))+    (TLit 5)+byteHighGuard = PCmp CmpGe (proj byteIdx) (TLit 250)++byteWrapFixture ::+  SymTransducer+    (HsPred ByteRegs AmtCmd)+    ByteRegs+    Bool+    AmtCmd+    ()+byteWrapFixture =+  SymTransducer+    { edgesOut = \case+        False ->+          [ Edge byteWrapGuard UKeep [] True,+            Edge byteHighGuard UKeep [] True+          ]+        True -> [],+      initial = False,+      initialRegs = RCons (Proxy @"byte") 0 RNil,+      isFinal = (== True)+    }++-- | A picosecond-time fixture whose guards overlap between two sub-second+-- bounds. Whole-second rounding used to turn this into an empty interval.+type TimeRegs = '[ '("at", UTCTime)]++timeIdx :: Index TimeRegs UTCTime+timeIdx = ZIdx++timeLower, timeUpper, timeWitness :: UTCTime+timeLower = posixSecondsToUTCTime 0.2+timeUpper = posixSecondsToUTCTime 0.9+timeWitness = posixSecondsToUTCTime 0.5++timeAfterGuard, timeBeforeGuard :: HsPred TimeRegs AmtCmd+timeAfterGuard = PCmp CmpGt (proj timeIdx) (TLit timeLower)+timeBeforeGuard = PCmp CmpLt (proj timeIdx) (TLit timeUpper)++timePrecisionFixture ::+  SymTransducer+    (HsPred TimeRegs AmtCmd)+    TimeRegs+    Bool+    AmtCmd+    ()+timePrecisionFixture =+  SymTransducer+    { edgesOut = \case+        False ->+          [ Edge timeAfterGuard UKeep [] True,+            Edge timeBeforeGuard UKeep [] True+          ]+        True -> [],+      initial = False,+      initialRegs = RCons (Proxy @"at") (posixSecondsToUTCTime 0) RNil,+      isFinal = (== True)+    }+ -- | A two-edge transducer over a 'Word64' register. Both edges leave -- the @False@ vertex; the second edge carries a constant 'Word64' -- equality that is always false (@5 == 6@), so the pair is mutually@@ -207,6 +279,43 @@  spec :: Spec spec = do+  describe "satResultIsProvablyUnsat" $ do+    it "treats Unknown as not provably empty" $ do+      let unknown = SBV.SatResult (SBV.Unknown SBV.z3 SBV.UnknownTimeOut)+      -- The old implementation negated modelExists, which turns Unknown into+      -- the unsound "provably empty" verdict pinned by this contrast.+      not (SBV.modelExists unknown) `shouldBe` True+      satResultIsProvablyUnsat unknown `shouldBe` False++    it "treats ProofError as not provably empty" $ do+      let proofError = SBV.SatResult (SBV.ProofError SBV.z3 ["boom"] Nothing)+      satResultIsProvablyUnsat proofError `shouldBe` False++    it "trusts a definite unsatisfiable result and rejects a satisfiable one" $ do+      unsatisfiable <- SBV.sat (pure SBV.sFalse :: SBV.Symbolic SBV.SBool)+      satisfiable <- SBV.sat (pure SBV.sTrue :: SBV.Symbolic SBV.SBool)+      satResultIsProvablyUnsat unsatisfiable `shouldBe` True+      satResultIsProvablyUnsat satisfiable `shouldBe` False++  describe "Either-arm predicates" $ do+    let leftTinyFoo :: InCtor (Either TinyCmd Bool) '[]+        leftTinyFoo =+          InCtor+            { icName = "TinyFoo",+              icMatch = \case Left (TinyFoo _) -> Just RNil; _ -> Nothing,+              icBuild = \RNil -> Left (TinyFoo 0)+            }++    it "proves Left and Right arms mutually exclusive" $+      symIsBot+        (PAnd PLeftArm PRightArm :: HsPred '[] (Either TinyCmd Bool))+        `shouldBe` True++    it "keeps an arm test satisfiable alongside a constructor test" $+      symIsBot+        (PAnd PLeftArm (PInCtor leftTinyFoo) :: HsPred '[] (Either TinyCmd Bool))+        `shouldBe` False+   describe "discoverSym (curated registry)" $ do     it "discovers Sym Bool" $ symKnown (Proxy @Bool) `shouldBe` True     it "discovers Sym Int" $ symKnown (Proxy @Int) `shouldBe` True@@ -253,6 +362,24 @@           (regs ! amountIdx) `shouldBe` (7 :: Word64)           cmd `shouldBe` AmtTick           evalPred p regs cmd `shouldBe` True++  describe "exact fixed-width and picosecond encodings" $ do+    it "finds a Word8 overlap that exists only through modular wraparound" $ do+      let runtimeRegs = RCons (Proxy @"byte") 255 RNil+      evalPred byteWrapGuard runtimeRegs AmtTick `shouldBe` True+      evalPred byteHighGuard runtimeRegs AmtTick `shouldBe` True+      checkTransitionDeterminismSym byteWrapFixture `shouldSatisfy` (not . null)+      isSingleValuedSym (withSymPred byteWrapFixture) `shouldBe` False++    it "round-trips UTCTime at sub-second precision" $ do+      fromSym (toSym timeWitness) `shouldBe` timeWitness++    it "finds an overlap between sub-second UTCTime bounds" $ do+      let runtimeRegs = RCons (Proxy @"at") timeWitness RNil+      evalPred timeAfterGuard runtimeRegs AmtTick `shouldBe` True+      evalPred timeBeforeGuard runtimeRegs AmtTick `shouldBe` True+      checkTransitionDeterminismSym timePrecisionFixture `shouldSatisfy` (not . null)+      isSingleValuedSym (withSymPred timePrecisionFixture) `shouldBe` False    describe "ordering predicate PCmp (EP-41 M2)" $ do     it "constant contradiction 5 >= 10 over Word64 is symIsBot" $
+ test/Keiki/ValidationReplayAlignmentSpec.hs view
@@ -0,0 +1,380 @@+module Keiki.ValidationReplayAlignmentSpec (spec) where++import Control.Exception (evaluate)+import Control.Monad (foldM)+import Data.Proxy (Proxy (..))+import Data.Text (Text)+import Data.Time (UTCTime (..), fromGregorian, secondsToDiffTime)+import Keiki.Core+import Keiki.Fixtures.EmailDelivery+import Keiki.Fixtures.RegisterEmission+import Keiki.Fixtures.SplitCoverage+import Keiki.Fixtures.UserRegistration+import Test.Hspec++runCommands ::+  (BoolAlg phi (RegFile rs, ci)) =>+  SymTransducer phi rs s ci co ->+  [ci] ->+  Maybe (s, RegFile rs, [co])+runCommands t = foldM advance (initial t, initialRegs t, [])+  where+    advance (s, regs, logSoFar) cmd = do+      (s', regs', emitted) <- step t (s, regs) cmd+      pure (s', regs', logSoFar ++ emitted)++atTime :: Integer -> UTCTime+atTime n = UTCTime (fromGregorian 2026 7 12) (secondsToDiffTime n)++data AmbiguousCmd = CmdX Int | CmdY Int+  deriving stock (Eq, Show)++data AmbiguousEvent = Logged Int | LoggedY Int+  deriving stock (Eq, Show)++type AmbiguousFields = '[ '("value", Int)]++inCtorX :: InCtor AmbiguousCmd AmbiguousFields+inCtorX =+  InCtor+    { icName = "CmdX",+      icMatch = \case CmdX value -> Just (RCons (Proxy @"value") value RNil); _ -> Nothing,+      icBuild = \(RCons _ value RNil) -> CmdX value+    }++inCtorY :: InCtor AmbiguousCmd AmbiguousFields+inCtorY =+  InCtor+    { icName = "CmdY",+      icMatch = \case CmdY value -> Just (RCons (Proxy @"value") value RNil); _ -> Nothing,+      icBuild = \(RCons _ value RNil) -> CmdY value+    }++wireLogged :: WireCtor AmbiguousEvent (Int, ())+wireLogged =+  WireCtor+    { wcName = "Logged",+      wcMatch = \case Logged value -> Just (value, ()); _ -> Nothing,+      wcBuild = \(value, ()) -> Logged value+    }++wireLoggedY :: WireCtor AmbiguousEvent (Int, ())+wireLoggedY =+  WireCtor+    { wcName = "LoggedY",+      wcMatch = \case LoggedY value -> Just (value, ()); _ -> Nothing,+      wcBuild = \(value, ()) -> LoggedY value+    }++ambiguousTransducerWith ::+  WireCtor AmbiguousEvent (Int, ()) ->+  SymTransducer (HsPred '[] AmbiguousCmd) '[] Bool AmbiguousCmd AmbiguousEvent+ambiguousTransducerWith secondWire =+  SymTransducer+    { edgesOut = \case+        False ->+          [ Edge+              { guard = matchInCtor inCtorX,+                update = UKeep,+                output =+                  [ pack+                      inCtorX+                      wireLogged+                      (TInpCtorField inCtorX (#value :: Index AmbiguousFields Int) *: oNil)+                  ],+                target = True+              },+            Edge+              { guard = matchInCtor inCtorY,+                update = UKeep,+                output =+                  [ pack+                      inCtorY+                      secondWire+                      (TInpCtorField inCtorY (#value :: Index AmbiguousFields Int) *: oNil)+                  ],+                target = True+              }+          ]+        True -> [],+      initial = False,+      initialRegs = RNil,+      isFinal = id+    }++ambiguousTransducer :: SymTransducer (HsPred '[] AmbiguousCmd) '[] Bool AmbiguousCmd AmbiguousEvent+ambiguousTransducer = ambiguousTransducerWith wireLogged++distinctHeadTransducer :: SymTransducer (HsPred '[] AmbiguousCmd) '[] Bool AmbiguousCmd AmbiguousEvent+distinctHeadTransducer = ambiguousTransducerWith wireLoggedY++type ReadRegs = '[ '("seen", Int)]++readGuardTransducer :: HsPred ReadRegs AmbiguousCmd -> SymTransducer (HsPred ReadRegs AmbiguousCmd) ReadRegs Bool AmbiguousCmd ()+readGuardTransducer edgeGuard =+  SymTransducer+    { edgesOut = \case+        False ->+          [ Edge+              { guard = edgeGuard,+                update =+                  USet+                    (#seen :: IndexN "seen" ReadRegs Int)+                    (TInpCtorField inCtorX (#value :: Index AmbiguousFields Int)),+                output = [],+                target = True+              }+          ]+        True -> [],+      initial = False,+      initialRegs = RCons (Proxy @"seen") 0 RNil,+      isFinal = id+    }++unguardedReadTransducer :: SymTransducer (HsPred ReadRegs AmbiguousCmd) ReadRegs Bool AmbiguousCmd ()+unguardedReadTransducer = readGuardTransducer PTop++safeReadTransducer :: SymTransducer (HsPred ReadRegs AmbiguousCmd) ReadRegs Bool AmbiguousCmd ()+safeReadTransducer = readGuardTransducer (PAnd (matchInCtor inCtorX) PTop)++wrongOrderReadTransducer :: SymTransducer (HsPred ReadRegs AmbiguousCmd) ReadRegs Bool AmbiguousCmd ()+wrongOrderReadTransducer =+  readGuardTransducer+    ( PAnd+        (PEq (TInpCtorField inCtorX (#value :: Index AmbiguousFields Int)) (TLit 7))+        (matchInCtor inCtorX)+    )++rightOrderReadTransducer :: SymTransducer (HsPred ReadRegs AmbiguousCmd) ReadRegs Bool AmbiguousCmd ()+rightOrderReadTransducer =+  readGuardTransducer+    ( PAnd+        (matchInCtor inCtorX)+        (PEq (TInpCtorField inCtorX (#value :: Index AmbiguousFields Int)) (TLit 7))+    )++data EpsilonVertex = EpsilonStart | EpsilonEnd+  deriving stock (Eq, Ord, Show, Enum, Bounded)++data EpsilonCase+  = ChangesVertexOnly+  | WritesRegistersOnly+  | ChangesBoth+  | NoOpSelfLoop++epsilonTransducer :: EpsilonCase -> SymTransducer (HsPred ReadRegs AmbiguousCmd) ReadRegs EpsilonVertex AmbiguousCmd ()+epsilonTransducer epsilonCase =+  SymTransducer+    { edgesOut = \case+        EpsilonStart ->+          case epsilonCase of+            ChangesVertexOnly ->+              [Edge (matchInCtor inCtorX) UKeep [] EpsilonEnd]+            WritesRegistersOnly ->+              [Edge (matchInCtor inCtorX) setSeen [] EpsilonStart]+            ChangesBoth ->+              [Edge (matchInCtor inCtorX) setSeen [] EpsilonEnd]+            NoOpSelfLoop ->+              [Edge (matchInCtor inCtorX) UKeep [] EpsilonStart]+        EpsilonEnd -> [],+      initial = EpsilonStart,+      initialRegs = RCons (Proxy @"seen") 0 RNil,+      isFinal = (== EpsilonEnd)+    }+  where+    setSeen =+      USet+        (#seen :: IndexN "seen" ReadRegs Int)+        (TInpCtorField inCtorX (#value :: Index AmbiguousFields Int))++spec :: Spec+spec = do+  describe "validate-clean transducers replay their own logs" $ do+    it "splitCoverageFixed replays its own log" $ do+      Just (forwardVertex, RNil, emitted) <-+        pure (runCommands splitCoverageFixed [Begin 1 2 3])+      emitted `shouldBe` [OutABC 1 2 3, OutBC 2 3]+      validateTransducer defaultValidationOptions splitCoverageFixed `shouldBe` []+      case reconstitute splitCoverageFixed emitted of+        Just (replayVertex, RNil) -> replayVertex `shouldBe` forwardVertex+        Nothing -> expectationFailure "splitCoverageFixed did not replay its own log"++    it "registerEmission replays command fields and TReg audit fields" $ do+      Just (forwardVertex, forwardRegs, emitted) <-+        pure (runCommands registerEmission registerCommands)+      emitted+        `shouldBe` [Opened "alice", Added 7 "alice", Closed "alice", Archived "alice"]+      validateTransducer defaultValidationOptions registerEmission `shouldBe` []+      case reconstitute registerEmission emitted of+        Just (replayVertex, replayRegs) -> do+          replayVertex `shouldBe` forwardVertex+          (replayRegs ! (#owner :: Index RegisterEmissionRegs Text))+            `shouldBe` (forwardRegs ! (#owner :: Index RegisterEmissionRegs Text))+          (replayRegs ! (#total :: Index RegisterEmissionRegs Int))+            `shouldBe` (forwardRegs ! (#total :: Index RegisterEmissionRegs Int))+        Nothing -> expectationFailure "registerEmission did not replay its own log"++    it "emailDelivery validates clean and replays its own log" $ do+      let cmd =+            SendEmail+              SendEmailData+                { recipient = "alice@example.com",+                  subject = "hello",+                  at = atTime 0+                }+      Just (forwardVertex, forwardRegs, emitted) <- pure (runCommands emailDelivery [cmd])+      validateTransducer defaultValidationOptions emailDelivery `shouldBe` []+      case reconstitute emailDelivery emitted of+        Just (replayVertex, replayRegs) -> do+          replayVertex `shouldBe` forwardVertex+          (replayRegs ! (#emailRecipient :: Index EmailRegs Text))+            `shouldBe` (forwardRegs ! (#emailRecipient :: Index EmailRegs Text))+          (replayRegs ! (#emailSubject :: Index EmailRegs Text))+            `shouldBe` (forwardRegs ! (#emailSubject :: Index EmailRegs Text))+          (replayRegs ! (#emailSentAt :: Index EmailRegs UTCTime))+            `shouldBe` (forwardRegs ! (#emailSentAt :: Index EmailRegs UTCTime))+        Nothing -> expectationFailure "emailDelivery did not replay its own log"++    it "userReg's persisted canonical path replays its own log" $ do+      let commands =+            [ StartRegistration (StartRegistrationData "alice@x" "Z9F4" (atTime 0)),+              ResendConfirmation (ResendConfirmationData "K2P7" (atTime 100)),+              ConfirmAccount (ConfirmAccountData "K2P7" (atTime 200)),+              FulfillGDPRRequest (FulfillGDPRRequestData (atTime 300))+            ]+      Just (forwardVertex, forwardRegs, emitted) <- pure (runCommands userReg commands)+      validateTransducer defaultValidationOptions userReg `shouldBe` []+      case reconstitute userReg emitted of+        Just (replayVertex, replayRegs) -> do+          replayVertex `shouldBe` forwardVertex+          (replayRegs ! (#email :: Index UserRegRegs Text))+            `shouldBe` (forwardRegs ! (#email :: Index UserRegRegs Text))+          (replayRegs ! (#confirmCode :: Index UserRegRegs Text))+            `shouldBe` (forwardRegs ! (#confirmCode :: Index UserRegRegs Text))+          (replayRegs ! (#registeredAt :: Index UserRegRegs UTCTime))+            `shouldBe` (forwardRegs ! (#registeredAt :: Index UserRegRegs UTCTime))+          (replayRegs ! (#confirmedAt :: Index UserRegRegs UTCTime))+            `shouldBe` (forwardRegs ! (#confirmedAt :: Index UserRegRegs UTCTime))+          (replayRegs ! (#deletedAt :: Index UserRegRegs UTCTime))+            `shouldBe` (forwardRegs ! (#deletedAt :: Index UserRegRegs UTCTime))+        Nothing -> expectationFailure "userReg did not replay its persisted path"++  describe "split-coverage counterexample" $ do+    it "produces a log that its current validator accepts but replay rejects" $ do+      Just (True, RNil, emitted) <- pure (runCommands splitCoverageBad [Begin 1 2 3])+      emitted `shouldBe` [OutAB 1 2, OutBC 2 3]+      case reconstitute splitCoverageBad emitted of+        Nothing -> pure ()+        Just _ -> expectationFailure "splitCoverageBad unexpectedly replayed its own log"++    it "validator flags the head-unrecoverable edge" $ do+      let warnings = validateTransducer defaultValidationOptions splitCoverageBad+          isHeadWarning+            ( HeadUnrecoverable+                { tvwEdge = EdgeRef {edgeSource = False, edgeIndex = 0},+                  tvwInCtor = Just "Begin",+                  tvwTailOnlySlots = ["c"]+                }+              ) = True+          isHeadWarning _ = False+      warnings `shouldSatisfy` any isHeadWarning++  describe "cross-edge inversion ambiguity" $ do+    it "predicts the replay failure for two equal head wire constructors" $ do+      Just (True, RNil, emitted) <- pure (runCommands ambiguousTransducer [CmdX 7])+      emitted `shouldBe` [Logged 7]+      case reconstitute ambiguousTransducer emitted of+        Nothing -> pure ()+        Just _ -> expectationFailure "same-head transducer unexpectedly replayed"+      let warnings = validateTransducer defaultValidationOptions ambiguousTransducer+          isAmbiguous+            ( InversionAmbiguity+                { tvwSource = False,+                  tvwEdgeA = 0,+                  tvwEdgeB = 1,+                  tvwWireCtor = "Logged"+                }+              ) = True+          isAmbiguous _ = False+      warnings `shouldSatisfy` any isAmbiguous++    it "distinct head wire constructors validate and replay" $ do+      Just (True, RNil, emitted) <- pure (runCommands distinctHeadTransducer [CmdY 9])+      emitted `shouldBe` [LoggedY 9]+      validateTransducer defaultValidationOptions distinctHeadTransducer `shouldBe` []+      case reconstitute distinctHeadTransducer emitted of+        Just (True, RNil) -> pure ()+        _ -> expectationFailure "distinct-head transducer did not replay"++  describe "guard implies input reads" $ do+    let isUnguarded+          ( UnguardedInputRead+              { tvwEdge = EdgeRef {edgeSource = False, edgeIndex = 0},+                tvwInCtor = Just "CmdX"+              }+            ) = True+        isUnguarded _ = False++    it "flags a PTop-guarded update read" $+      guardImpliesInputReadWarnings unguardedReadTransducer+        `shouldSatisfy` any isUnguarded++    it "accepts a read protected by an earlier constructor guard" $ do+      guardImpliesInputReadWarnings safeReadTransducer `shouldBe` []+      case step safeReadTransducer (False, initialRegs safeReadTransducer) (CmdY 3) of+        Nothing -> pure ()+        Just _ -> expectationFailure "safe constructor guard accepted CmdY"++    it "flags a guard read that appears before its constructor guard" $+      guardImpliesInputReadWarnings wrongOrderReadTransducer+        `shouldSatisfy` any isUnguarded++    it "accepts a guard read after its constructor guard" $+      guardImpliesInputReadWarnings rightOrderReadTransducer `shouldBe` []++    it "predicts the runtime TInpCtorField crash" $+      evaluate+        ( case step unguardedReadTransducer (False, initialRegs unguardedReadTransducer) (CmdY 3) of+            Just (_, regs, _) -> regs ! (#seen :: Index ReadRegs Int)+            Nothing -> 0+        )+        `shouldThrow` errorCall "evalTerm: TInpCtorField guard violation: CmdX"++  describe "state-changing epsilon" $ do+    let warningShape transducer =+          [ (tvwChangesVertex, tvwWritesRegisters)+          | StateChangingEpsilon+              { tvwEdge = EdgeRef {edgeSource = EpsilonStart, edgeIndex = 0},+                tvwChangesVertex,+                tvwWritesRegisters+              } <-+              stateChangingEpsilonWarnings transducer+          ]++    it "reports vertex-only, register-only, and combined changes exactly" $ do+      warningShape (epsilonTransducer ChangesVertexOnly) `shouldBe` [(True, False)]+      warningShape (epsilonTransducer WritesRegistersOnly) `shouldBe` [(False, True)]+      warningShape (epsilonTransducer ChangesBoth) `shouldBe` [(True, True)]++    it "keeps a UKeep self-loop clean" $+      validateTransducer defaultValidationOptions (epsilonTransducer NoOpSelfLoop)+        `shouldBe` []++    it "allows only this check to be disabled explicitly" $+      validateTransducer+        defaultValidationOptions {checkStateChangingEpsilon = False}+        (epsilonTransducer ChangesVertexOnly)+        `shouldBe` []++    it "predicts empty-log replay divergence" $ do+      let transducer = epsilonTransducer ChangesVertexOnly+      Just (EpsilonEnd, _, emitted) <- pure (runCommands transducer [CmdX 7])+      emitted `shouldBe` []+      case reconstitute transducer emitted of+        Just (EpsilonStart, _) -> pure ()+        _ -> expectationFailure "empty log unexpectedly reproduced the forward vertex"++    it "does not let the hidden-input and state-change checks mask each other" $ do+      let warnings = validateTransducer defaultValidationOptions (epsilonTransducer ChangesBoth)+      warnings `shouldSatisfy` any (\case HiddenInput {} -> True; _ -> False)+      warnings `shouldSatisfy` any (\case StateChangingEpsilon {} -> True; _ -> False)
test/Keiki/ValidationSpec.hs view
@@ -2,6 +2,7 @@  import Data.List (isInfixOf) import Data.Proxy (Proxy (..))+import Data.Word (Word8) import Keiki.Core import Keiki.Symbolic (checkDeadEdgesSym, checkTransitionDeterminismSym) import Test.Hspec@@ -26,6 +27,25 @@       icBuild = \RNil -> Bar     } +data VEvent = Fooed | Bared+  deriving stock (Eq, Show)++wireFooed :: WireCtor VEvent ()+wireFooed =+  WireCtor+    { wcName = "Fooed",+      wcMatch = \case Fooed -> Just (); _ -> Nothing,+      wcBuild = \() -> Fooed+    }++wireBared :: WireCtor VEvent ()+wireBared =+  WireCtor+    { wcName = "Bared",+      wcMatch = \case Bared -> Just (); _ -> Nothing,+      wcBuild = \() -> Bared+    }+ -- A three-state enum: Start (reachable), Mid (reachable), Orphan (unreachable). data V = Start | Mid | Orphan   deriving stock (Eq, Ord, Show, Enum, Bounded)@@ -73,13 +93,13 @@ -- (d) a clean transducer: mutually exclusive guards, every vertex with edges -- is reachable, no overlapping/PBot guards. (Orphan has no outgoing edges, so -- although it is structurally unreachable it contributes no edge to flag.)-cleanT :: SymTransducer (HsPred '[] Cmd) '[] V Cmd ()+cleanT :: SymTransducer (HsPred '[] Cmd) '[] V Cmd VEvent cleanT =   SymTransducer     { edgesOut = \case         Start ->-          [ Edge {guard = matchInCtor inCtorFoo, update = UKeep, output = [], target = Mid},-            Edge {guard = matchInCtor inCtorBar, update = UKeep, output = [], target = Mid}+          [ Edge {guard = matchInCtor inCtorFoo, update = UKeep, output = [pack inCtorFoo wireFooed oNil], target = Mid},+            Edge {guard = matchInCtor inCtorBar, update = UKeep, output = [pack inCtorBar wireBared oNil], target = Mid}           ]         _ -> [],       initial = Start,@@ -111,7 +131,7 @@ -- no structural keiki node, so it is forced through TApp1. type ItemRegs = '[ '("items", [Int])] -opaqueT :: SymTransducer (HsPred ItemRegs Cmd) ItemRegs V Cmd ()+opaqueT :: SymTransducer (HsPred ItemRegs Cmd) ItemRegs V Cmd VEvent opaqueT =   SymTransducer     { edgesOut = \case@@ -122,7 +142,7 @@                     (TApp1 (5 `elem`) (TReg (ZIdx :: Index ItemRegs [Int])))                     (TLit True),                 update = UKeep,-                output = [],+                output = [pack inCtorFoo wireFooed oNil],                 target = Mid               }           ]@@ -190,6 +210,141 @@       isFinal = id     } +-- Pure-overlap fixtures (EP-76). Every edge is a state-preserving self-loop so+-- the default validation result isolates determinism from epsilon-state-change+-- diagnostics.+type OverlapRegs = '[ '("x", Int)]++xIdx :: Index OverlapRegs Int+xIdx = ZIdx++overlapFixture ::+  HsPred OverlapRegs Cmd ->+  HsPred OverlapRegs Cmd ->+  SymTransducer (HsPred OverlapRegs Cmd) OverlapRegs V Cmd ()+overlapFixture leftGuard rightGuard =+  SymTransducer+    { edgesOut = \case+        Start ->+          [ Edge leftGuard UKeep [] Start,+            Edge rightGuard UKeep [] Start+          ]+        _ -> [],+      initial = Start,+      initialRegs = RCons (Proxy @"x") 0 RNil,+      isFinal = const False+    }++fooWith :: HsPred OverlapRegs Cmd -> HsPred OverlapRegs Cmd+fooWith = PAnd (PInCtor inCtorFoo)++barWith :: HsPred OverlapRegs Cmd -> HsPred OverlapRegs Cmd+barWith = PAnd (PInCtor inCtorBar)++motivatingOverlapT ::+  SymTransducer (HsPred OverlapRegs Cmd) OverlapRegs V Cmd ()+motivatingOverlapT =+  overlapFixture+    (fooWith (PCmp CmpGt (proj xIdx) (TLit 0)))+    (fooWith (PCmp CmpGt (proj xIdx) (TLit 5)))++disjointOverlapT ::+  SymTransducer (HsPred OverlapRegs Cmd) OverlapRegs V Cmd ()+disjointOverlapT =+  overlapFixture+    (fooWith (PCmp CmpGt (proj xIdx) (TLit 5)))+    (fooWith (PCmp CmpLt (proj xIdx) (TLit 3)))++unknownOrT ::+  SymTransducer (HsPred OverlapRegs Cmd) OverlapRegs V Cmd ()+unknownOrT =+  overlapFixture+    ( fooWith+        ( POr+            (PCmp CmpGt (proj xIdx) (TLit 0))+            (PCmp CmpLt (proj xIdx) (TLit 0))+        )+    )+    (fooWith (PCmp CmpGt (proj xIdx) (TLit 5)))++unknownOpaqueT ::+  SymTransducer (HsPred OverlapRegs Cmd) OverlapRegs V Cmd ()+unknownOpaqueT =+  overlapFixture+    ( fooWith+        (PCmp CmpGt (TApp1 id (proj xIdx)) (TLit 0))+    )+    (fooWith (PCmp CmpGt (proj xIdx) (TLit 5)))++differentCtorT ::+  SymTransducer (HsPred OverlapRegs Cmd) OverlapRegs V Cmd ()+differentCtorT =+  overlapFixture+    (fooWith (PCmp CmpGt (proj xIdx) (TLit 0)))+    (barWith (PCmp CmpGt (proj xIdx) (TLit 5)))++type ByteOverlapRegs = '[ '("x", Word8)]++byteOverlapIdx :: Index ByteOverlapRegs Word8+byteOverlapIdx = ZIdx++disjointWord8T ::+  SymTransducer (HsPred ByteOverlapRegs Cmd) ByteOverlapRegs V Cmd ()+disjointWord8T =+  SymTransducer+    { edgesOut = \case+        Start ->+          [ Edge+              ( PAnd+                  (PInCtor inCtorFoo)+                  (PCmp CmpGe (proj byteOverlapIdx) (TLit 200))+              )+              UKeep+              []+              Start,+            Edge+              ( PAnd+                  (PInCtor inCtorFoo)+                  (PCmp CmpLe (proj byteOverlapIdx) (TLit 100))+              )+              UKeep+              []+              Start+          ]+        _ -> [],+      initial = Start,+      initialRegs = RCons (Proxy @"x") 0 RNil,+      isFinal = const False+    }++type BoolOverlapRegs = '[ '("x", Bool)]++boolOverlapIdx :: Index BoolOverlapRegs Bool+boolOverlapIdx = ZIdx++boolLiteralWitnessT ::+  SymTransducer (HsPred BoolOverlapRegs Cmd) BoolOverlapRegs V Cmd ()+boolLiteralWitnessT =+  SymTransducer+    { edgesOut = \case+        Start ->+          [ Edge+              (PAnd (PInCtor inCtorFoo) (PEq (proj boolOverlapIdx) (TLit True)))+              UKeep+              []+              Start,+            Edge+              (PAnd (PInCtor inCtorFoo) (PEq (TLit True) (proj boolOverlapIdx)))+              UKeep+              []+              Start+          ]+        _ -> [],+      initial = Start,+      initialRegs = RCons (Proxy @"x") False RNil,+      isFinal = const False+    }+ spec :: Spec spec = do   describe "validateTransducer (pure, no solver)" $ do@@ -250,9 +405,57 @@     it "mutually-exclusive PInCtor guards yield no determinism warning" $       checkTransitionDeterminismSym cleanT `shouldBe` [] -    it "catches a PTop-vs-PInCtor overlap the pure path cannot prove" $ do-      checkTransitionDeterminismPure symOverlapT `shouldBe` []+    it "agrees with the pure path on a PTop-vs-PInCtor overlap" $ do+      checkTransitionDeterminismPure symOverlapT `shouldSatisfy` (not . null)       checkTransitionDeterminismSym symOverlapT `shouldSatisfy` (not . null)++  describe "provable overlap through PAnd spines" $ do+    let determinismWarningsOnly = filter isDeterminismWarning+        isDeterminismWarning (NondeterministicPair {}) = True+        isDeterminismWarning _ = False+        warningPair warning = (dwSource warning, dwEdgeA warning, dwEdgeB warning)+        pureIsSubsetOfSymbolic fixture = do+          let purePairs = map warningPair (checkTransitionDeterminismPure fixture)+              symbolicPairs = map warningPair (checkTransitionDeterminismSym fixture)+          purePairs `shouldSatisfy` all (`elem` symbolicPairs)++    it "finds the motivating same-constructor integral overlap" $ do+      determinismWarningsOnly+        (validateTransducer defaultValidationOptions motivatingOverlapT)+        `shouldBe` [ NondeterministicPair+                       { tvwSource = Start,+                         tvwEdgeA = 0,+                         tvwEdgeB = 1,+                         tvwInCtor = Just "Foo",+                         tvwDetail =+                           "edges #0 and #1 out of Start have overlapping guards"+                       }+                   ]++    it "does not warn for disjoint integral intervals" $ do+      checkTransitionDeterminismPure disjointOverlapT `shouldBe` []+      checkTransitionDeterminismPure disjointWord8T `shouldBe` []++    it "uses a mentioned non-integral literal as a concrete witness" $+      checkTransitionDeterminismPure boolLiteralWitnessT+        `shouldSatisfy` (not . null)++    it "does not guess through POr or an opaque TApp term" $ do+      checkTransitionDeterminismPure unknownOrT `shouldBe` []+      checkTransitionDeterminismPure unknownOpaqueT `shouldBe` []++    it "does not warn across different input constructors" $+      checkTransitionDeterminismPure differentCtorT `shouldBe` []++    it "keeps every pure warning inside the symbolic result" $ do+      mapM_+        pureIsSubsetOfSymbolic+        [ motivatingOverlapT,+          disjointOverlapT,+          unknownOrT,+          unknownOpaqueT,+          differentCtorT+        ]    describe "checkDeadEdgesSym (z3-backed)" $ do     it "flags a literal-PBot guard as unsatisfiable in isolation" $ do
test/Spec.hs view
@@ -4,19 +4,22 @@ import Keiki.ArrowSpec qualified import Keiki.BuilderSpec qualified import Keiki.BuilderSpike qualified+import Keiki.BuilderTypeErrorsSpec qualified import Keiki.CategorySpec qualified import Keiki.ChoiceSpec qualified import Keiki.CollectionSpike qualified+import Keiki.CompositionAlignmentSpec qualified import Keiki.CompositionAlternativeSpec qualified import Keiki.CompositionFeedback1Spec qualified+import Keiki.CompositionHomomorphismSpec qualified import Keiki.CompositionMultiEventSpec qualified import Keiki.CompositionNarySpec qualified import Keiki.CompositionSpec qualified+import Keiki.CompositionStatefulSpec qualified import Keiki.CoreApplyEventsSpec qualified import Keiki.CoreHiddenInputsGSMSpec qualified import Keiki.CoreInFlightSpec qualified import Keiki.CoreSpec qualified-import Keiki.DeciderSpec qualified import Keiki.Generics.THSpec qualified import Keiki.NoThunksSpec qualified import Keiki.OperatorsQualifiedSpec qualified@@ -28,10 +31,13 @@ import Keiki.Render.MermaidSpec qualified import Keiki.Render.PrettySpec qualified import Keiki.Render.ValidateSpec qualified+import Keiki.ReplayEitherSpec qualified+import Keiki.RoundTripSpec qualified import Keiki.ShapeSpec qualified import Keiki.StepEitherSpec qualified import Keiki.StrongSpec qualified import Keiki.SymbolicSpec qualified+import Keiki.ValidationReplayAlignmentSpec qualified import Keiki.ValidationSpec qualified import Test.Hspec @@ -40,19 +46,22 @@   describe "Keiki.Acceptor" Keiki.AcceptorSpec.spec   describe "Keiki.Builder (EP-15 M6)" Keiki.BuilderSpec.spec   describe "Keiki.BuilderSpike (EP-15 M2)" Keiki.BuilderSpike.spec+  describe "Keiki.Builder type errors (EP-70)" Keiki.BuilderTypeErrorsSpec.spec   describe "Keiki.Profunctor (Category, EP-28)" Keiki.CategorySpec.spec   describe "Keiki.Profunctor (Choice, EP-29 M1)" Keiki.ChoiceSpec.spec   describe "Keiki.Composition" Keiki.CompositionSpec.spec+  describe "Keiki.Composition alignment (EP-75)" Keiki.CompositionAlignmentSpec.spec   describe "Keiki.Composition (alternative, EP-25)" Keiki.CompositionAlternativeSpec.spec   describe "Keiki.Composition (feedback1, EP-26)" Keiki.CompositionFeedback1Spec.spec+  describe "Keiki.Composition (homomorphism, EP-74)" Keiki.CompositionHomomorphismSpec.spec   describe "Keiki.Composition (multi-event, EP-19 M6)" Keiki.CompositionMultiEventSpec.spec   describe "Keiki.Composition (N-ary codec, EP-48)" Keiki.CompositionNarySpec.spec+  describe "Keiki.Composition (stateful, EP-74)" Keiki.CompositionStatefulSpec.spec   describe "Keiki.Core" Keiki.CoreSpec.spec   describe "Keiki.Core.stepEither (EP-55)" Keiki.StepEitherSpec.spec   describe "Keiki.Core.applyEvents (EP-20 M2)" Keiki.CoreApplyEventsSpec.spec   describe "Keiki.Core.InFlight / streaming (EP-19 M3)" Keiki.CoreInFlightSpec.spec   describe "Keiki.Core.checkHiddenInputs (EP-19 M4 union)" Keiki.CoreHiddenInputsGSMSpec.spec-  describe "Keiki.Decider" Keiki.DeciderSpec.spec   describe "Keiki.Generics.TH" Keiki.Generics.THSpec.spec   describe "Keiki.NoThunks" Keiki.NoThunksSpec.spec   describe "Keiki.Core operators (EP-45)" Keiki.OperatorsSpec.spec@@ -61,6 +70,8 @@   describe "Keiki.Profunctor (Strong, EP-29 M2)" Keiki.StrongSpec.spec   describe "Keiki.Profunctor (Arrow, EP-29 M3)" Keiki.ArrowSpec.spec   describe "Keiki.RecomputeVerify (EP-47)" Keiki.RecomputeVerifySpec.spec+  describe "Keiki.RoundTrip (EP-73)" Keiki.RoundTripSpec.spec+  describe "Keiki.Core structured replay (EP-72)" Keiki.ReplayEitherSpec.spec   describe "Keiki.Render.Inspector (EP-62)" Keiki.Render.InspectorSpec.spec   describe "Keiki.Render.Markdown (EP-65)" Keiki.Render.MarkdownSpec.spec   describe "Keiki.Render.Mermaid (EP-30, EP-31, EP-32, EP-33)" Keiki.Render.MermaidSpec.spec@@ -70,3 +81,4 @@   describe "Keiki.Symbolic" Keiki.SymbolicSpec.spec   describe "Keiki.CollectionSpike (EP-60 M1 ratification gate)" Keiki.CollectionSpike.spec   describe "Keiki.Core.validateTransducer (EP-56)" Keiki.ValidationSpec.spec+  describe "Keiki.ValidationReplayAlignmentSpec" Keiki.ValidationReplayAlignmentSpec.spec