keiki-0.1.0.0: test/Keiki/RecomputeVerifySpec.hs
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE QualifiedDo #-}
{-# LANGUAGE TemplateHaskell #-}
-- deriveAggregateCtors also emits an @is<Short>@ guard predicate per ctor,
-- which these fixtures do not all use.
{-# OPTIONS_GHC -Wno-unused-top-binds #-}
-- | EP-47 — recompute-and-verify derived event outputs, tested against the
-- real (relaxed) 'Keiki.Core.solveOutput'.
--
-- An order-cart aggregate stores a /derived/ output field
-- @lineTotal = quantity * unitPrice@. Three groups:
--
-- (i) round-trip — the derived-total event replays through 'applyEvents',
-- and a tampered total is rejected;
-- (ii) determinism — over a grid of commands, every command round-trips to
-- exactly itself and distinct commands never collide on one event;
-- (iii) negative — a /hidden input/ (a command slot read only inside a
-- derived field) is still flagged at build time by 'checkHiddenInputs'.
--
-- (The EP-47 M1 prototype validated the same logic with a local function
-- before the core change; that is now subsumed by exercising the production
-- 'solveOutput'/'applyEvents' directly.)
module Keiki.RecomputeVerifySpec (spec) where
import Data.List (isInfixOf, nub)
import Data.Maybe (isNothing)
import Data.Proxy (Proxy (..))
import GHC.Generics (Generic)
import Keiki.Builder ((.=))
import Keiki.Builder qualified as B
import Keiki.Core
( Edge (..),
HiddenInputWarning (..),
HsPred (..),
Index,
OutTerm,
RegFile (..),
SymTransducer (..),
Update (..),
applyEvents,
checkHiddenInputs,
evalOut,
pack,
solveOutput,
(!),
(.*),
)
import Keiki.Generics (emptyRegFile)
import Keiki.Generics.TH (deriveAggregateCtors, deriveWireCtors)
import Test.Hspec
-- * Order-cart fixture --------------------------------------------------
data AddData = AddData {quantity :: Int, unitPrice :: Int}
deriving (Eq, Show, Generic)
data CartCmd = AddLineItem AddData
deriving (Eq, Show, Generic)
-- The event mirrors quantity/unitPrice and ALSO stores a derived total.
data AddedData = AddedData {quantity :: Int, unitPrice :: Int, lineTotal :: Int}
deriving (Eq, Show, Generic)
data CartEvt = LineItemAdded AddedData
deriving (Eq, Show, Generic)
type CartRegs = '[ '("quantity", Int), '("unitPrice", Int)]
data CartV = CartOpen
deriving (Eq, Show, Enum, Bounded)
$(deriveAggregateCtors ''CartCmd ''CartRegs [("AddLineItem", "Add")])
$(deriveWireCtors ''CartEvt [("LineItemAdded", "Added")])
emptyCartRegs :: RegFile CartRegs
emptyCartRegs = emptyRegFile
-- The well-formed aggregate: quantity and unitPrice are plain command-field
-- projections in the event (the invertible fields that recover the command);
-- lineTotal is the redundant derived field @quantity * unitPrice@.
cart :: SymTransducer (HsPred CartRegs CartCmd) CartRegs CartV CartCmd CartEvt
cart = B.buildTransducer CartOpen emptyCartRegs (const True) do
B.from CartOpen do
B.onCmd inCtorAdd $ \d -> B.do
B.slot @"quantity" .= d.quantity
B.slot @"unitPrice" .= d.unitPrice
B.emit
wireAdded
AddedTermFields
{ quantity = d.quantity,
unitPrice = d.unitPrice,
lineTotal = d.quantity .* d.unitPrice
}
B.goto CartOpen
-- The head output term of the cart edge, for the determinism group.
cartOut :: OutTerm CartRegs CartCmd CartEvt
cartOut = case edgesOut cart CartOpen of
e : _ | o : _ <- output e -> o
_ -> error "RecomputeVerifySpec: cart edge/output missing"
-- * A malformed variant: quantity hidden inside the derived field --------
data BadData = BadData {unitPrice :: Int, total :: Int}
deriving (Eq, Show, Generic)
data BadEvt = BadAdded BadData
deriving (Eq, Show, Generic)
$(deriveWireCtors ''BadEvt [("BadAdded", "Bad")])
-- The output recovers only unitPrice invertibly; quantity is read ONLY
-- inside the derived @total@ field, so the command cannot be recovered —
-- a genuine hidden input that checkHiddenInputs must still flag.
badOut :: OutTerm CartRegs CartCmd BadEvt
badOut =
pack
inCtorAdd
wireBad
( B.toOutFields
BadTermFields
{ unitPrice = inpAdd #unitPrice,
total = inpAdd #quantity .* inpAdd #unitPrice
}
)
badCart :: SymTransducer (HsPred CartRegs CartCmd) CartRegs CartV CartCmd BadEvt
badCart =
SymTransducer
{ edgesOut = \CartOpen ->
[ Edge
{ guard = PInCtor inCtorAdd,
update = UKeep,
output = [badOut],
target = CartOpen
}
],
initial = CartOpen,
initialRegs = emptyCartRegs,
isFinal = const True
}
-- * A derived field that reads a REGISTER (not just command fields) -------
-- A `rate` register (state) and a Charge { qty } command. The event stores
-- qty (invertible) and a derived `amountDue = rate * qty`, where `rate` is
-- read from the register. This is the case where recompute-and-verify
-- depends on the register holding its emit-time value.
type RateRegs = '[ '("rate", Int)]
data ChargeCmdData = ChargeCmdData {qty :: Int}
deriving (Eq, Show, Generic)
data RateCmd = Charge ChargeCmdData
deriving (Eq, Show, Generic)
data ChargedData = ChargedData {qty :: Int, amountDue :: Int}
deriving (Eq, Show, Generic)
data RateEvt = Charged ChargedData
deriving (Eq, Show, Generic)
$(deriveAggregateCtors ''RateCmd ''RateRegs [("Charge", "Charge")])
$(deriveWireCtors ''RateEvt [("Charged", "Charged")])
-- amountDue = #rate * d.qty — a TArith over a register read and a command
-- field. qty is the invertible field that recovers the command; rate is
-- state (not a command slot), so there is no hidden input.
chargeOut :: OutTerm RateRegs RateCmd RateEvt
chargeOut =
pack
inCtorCharge
wireCharged
( B.toOutFields
ChargedTermFields
{ qty = inpCharge #qty,
amountDue = B.reg @"rate" .* inpCharge #qty
}
)
ratedRegs :: Int -> RegFile RateRegs
ratedRegs r = RCons (Proxy @"rate") r RNil
spec :: Spec
spec = do
describe "EP-47 (i): a derived-total event round-trips" $ do
it "applyEvents replays LineItemAdded {3,7,21} and reconstructs the registers" $
case applyEvents
cart
(initial cart, initialRegs cart)
[LineItemAdded (AddedData 3 7 21)] of
Just (s, regs) ->
(s, regs ! (#quantity :: Index CartRegs Int), regs ! (#unitPrice :: Index CartRegs Int))
`shouldBe` (CartOpen, 3, 7)
Nothing -> expectationFailure "expected the derived-total event to round-trip"
it "rejects a tampered lineTotal (3*7 = 21 /= 999)" $
isNothing
( applyEvents
cart
(initial cart, initialRegs cart)
[LineItemAdded (AddedData 3 7 999)]
)
`shouldBe` True
describe "EP-47 (ii): event determines command (determinism preserved)" $ do
let grid = [AddLineItem (AddData q u) | q <- [0 .. 5], u <- [0 .. 5]]
emit c = evalOut cartOut emptyCartRegs c
it "every command in the grid round-trips through solveOutput to itself" $
[solveOutput cartOut emptyCartRegs (emit c) | c <- grid]
`shouldBe` map Just grid
it "distinct commands never collide on one observed event" $
length (nub (map emit grid)) `shouldBe` length grid
describe "EP-47 (iii): a hidden input still fails the build-time check" $ do
it "checkHiddenInputs flags the well-formed cart with NO warning" $
checkHiddenInputs cart `shouldBe` []
it "checkHiddenInputs flags badCart: quantity read only inside the derived field" $ do
let warnings = checkHiddenInputs badCart
length warnings `shouldSatisfy` (>= 1)
let reasons = map hiwReason warnings
any (\r -> "AddLineItem" `isInfixOf` r && "quantity" `isInfixOf` r) reasons
`shouldBe` True
describe "EP-47 (iv): a register-reading derived field is verified against the registers" $ do
-- This pins the documented limitation: recompute-and-verify of a derived
-- field that reads a REGISTER depends on the register holding its
-- emit-time value. That holds for a full reconstitute and for replay from
-- a valid snapshot, but not for a synthetic mid-state with stale/empty
-- registers. A plain TReg audit field, by contrast, is invertible and is
-- NOT verified, so it round-trips regardless.
let cmd = Charge (ChargeCmdData 3)
charged = evalOut chargeOut (ratedRegs 10) cmd -- Charged { qty = 3, amountDue = 30 }
it "round-trips when the register holds its emit-time value (rate = 10)" $
solveOutput chargeOut (ratedRegs 10) charged `shouldBe` Just cmd
it "is rejected when replayed against an inconsistent register file (rate = 99)" $
-- amountDue recomputes as 99*3 = 297 /= the observed 30, so verification
-- fails. (The command qty IS still recovered; only the derived field's
-- forward recompute mismatches.)
solveOutput chargeOut (ratedRegs 99) charged `shouldBe` Nothing
it "a command-field-only derived field (cart lineTotal) is register-independent" $
-- cartOut's lineTotal reads only command fields, so any register file
-- works — contrast with the register-reading case above.
solveOutput cartOut emptyCartRegs (LineItemAdded (AddedData 3 7 21))
`shouldBe` Just (AddLineItem (AddData 3 7))