packages feed

keiki-0.1.0.0: test/Keiki/CollectionSpike.hs

-- | EP-60 M1 ratification-gate spike — first-class collection registers.
--
-- This module is a __prototype__, not the final implementation. Per the EP-60
-- ratification gate it deliberately touches __no__ file under @src/Keiki/@: it
-- models the proposed collection vocabulary (FR1–FR6 of
-- @docs/research/collection-registers-design.md@) as a /local mini-AST/ and proves,
-- by construction plus runnable @hspec@ assertions, that the design satisfies its
-- invariants (INV1–INV6) before any core edit is made.
--
-- What it demonstrates:
--
--   * __FR1\/FR2\/FR4 (zero-@TApp@ authoring).__ A @BlockerBoard@ aggregate
--     (@Map BlockerId BlockerState@) is authored entirely with structural
--     constructors — @CInsert@\/@CDelete@\/@CAdjust@ carrying /terms/, never
--     closures. A structural walker confirms the whole program contains zero
--     opaque escape hatches.
--   * __INV1 (derived replay).__ A forward fold of the structural updates
--     reconstitutes the same @Map@ a reference @Data.Map@ fold produces, over a
--     finite enumeration of command sequences — no hand-written @apply@.
--   * __INV2 (@solveOutput@ invertibility).__ A model of @stepOne@'s
--     recoverability classification shows @TLookupField@ joins @TReg@ on the
--     /structural/ side (register-recoverable), distinct from the opaque @TApp@
--     side — so an edge emitting a @TLookupField@-derived field stays invertible.
--   * __INV3 (@checkHiddenInputs@ understands collection updates).__ A model of
--     the @updateReadsInput@ + union-coverage walk flags a silent ε-edge insert
--     while passing an insert whose element data is on the wire.
--   * __INV4 (static output arity).__ Each edge's output length is constant,
--     independent of board size.
--   * __INV6 (NoThunks).__ A long replay over a strict map yields a fully forced
--     board (no thunk tower), by construction.
--   * __FR6 (symbolic translation).__ Option B is modeled as a /named, queryable/
--     @SymStatus@: a collection guard yields @SkippedCollectionGuard "PMember"@
--     (honest and inspectable), a scalar guard yields @Verified@ — never a silent
--     free Boolean that the single-valuedness gate would trust blindly.
--
-- The accompanying written analysis (FR6 A-vs-B decision, Seihou reconciliation,
-- INV1–INV6 satisfiability argument) lives in the EP-60 plan,
-- @docs\/plans\/60-first-class-collection-registers-design-gated.md@.
module Keiki.CollectionSpike (spec) where

import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
-- Real keiki, used only to demonstrate the scalar path is untouched (INV5).
import Keiki.Core (RegFile (..), Term (TLit), evalTerm)
import Test.Hspec

-- ---------------------------------------------------------------------------
-- The BlockerBoard domain (the §6 worked example, in miniature)
-- ---------------------------------------------------------------------------

type BlockerId = Int

data Status = Open | Resolved | Escalated
  deriving (Eq, Show)

-- | An element record. FR1 wants the element type expressible as a sub-record
-- so its fields are projectable by FR4's 'TLookupField'; here that is an
-- ordinary record with two fields.
data BlockerState = BlockerState
  { bsStatus :: !Status,
    bsSeverity :: !Int
  }
  deriving (Eq, Show)

type Board = Map BlockerId BlockerState

-- | The command sum (@ci@ in keiki terms). Positional rather than record-style
-- so @severity@ is not a partial field (it exists only on 'AddBlocker').
data Cmd
  = AddBlocker BlockerId Int
  | ResolveBlocker BlockerId
  | EscalateBlocker BlockerId
  deriving (Eq, Show)

-- | The blocker id every command carries (total).
cId :: Cmd -> BlockerId
cId (AddBlocker i _) = i
cId (ResolveBlocker i) = i
cId (EscalateBlocker i) = i

-- | The severity field, present only on 'AddBlocker'. Read only by the
-- @AddBlocker@ edge's term, which fires only on an @AddBlocker@ command —
-- mirroring how keiki's 'Keiki.Core.TInpCtorField' read is guarded by the
-- edge's 'InCtor' match.
cSeverity :: Cmd -> Int
cSeverity (AddBlocker _ s) = s
cSeverity c = error ("cSeverity: not an AddBlocker: " ++ show c)

-- ---------------------------------------------------------------------------
-- FR1/FR2/FR4: a local model of the structural term + update vocabulary
-- ---------------------------------------------------------------------------

-- | A miniature of keiki's 'Keiki.Core.Term', restricted to the four
-- recoverability classes that matter to @solveOutput@\/@stepOne@. Concrete at
-- @ci ~ Cmd@ for the spike.
--
-- The first three constructors are /structural/ — an analysis can read them. The
-- last, 'KClosure', is the opaque @TApp@ escape hatch the collection feature
-- exists to /avoid/ for element operations; it appears here only as the negative
-- contrast in the zero-@TApp@ and recoverability tests.
data KTerm a where
  -- | @TLit@: a constant. Structural, carries no command information.
  KLit :: a -> KTerm a
  -- | @TInpCtorField@: read a named field of the command. Structural and
  --     /on the wire/ (recoverable from the emitted event when it carries it).
  KInpField :: String -> (Cmd -> a) -> KTerm a
  -- | @TLookupField@ (FR4): read field @field@ of the collection element at
  --     @key@. The @(BlockerState -> a)@ stands for the structural element-field
  --     @Index velems f@ (a record selector, not an opaque command transform).
  --     Structural and recoverable from the /replayed register file/ — it joins
  --     @TReg@, not @TApp@.
  KLookup :: String -> KTerm BlockerId -> String -> (BlockerState -> a) -> KTerm a
  -- | @TApp1@\/@TApp2@: opaque closure over the command. Analysis-blind.
  KClosure :: String -> (Cmd -> a) -> KTerm a

-- | Evaluate a 'KTerm' forward against the current board and command — the
-- spike's analogue of 'Keiki.Core.evalTerm'.
evalK :: Board -> Cmd -> KTerm a -> a
evalK _ _ (KLit a) = a
evalK _ ci (KInpField _ f) = f ci
evalK b ci (KLookup _ keyT _ proj) = proj (b Map.! evalK b ci keyT)
evalK _ ci (KClosure _ f) = f ci

-- | FR2 structural update combinators, carrying /terms/ not closures. The
-- element value of 'CInsert' is itself a structural record-builder ('ElemBuild')
-- so the whole insert is closure-free.
data CUpdate
  = CInsert (KTerm BlockerId) ElemBuild
  | CDelete (KTerm BlockerId)
  | CAdjust (KTerm BlockerId) ElemUpd

-- | A structural builder for a 'BlockerState' element from per-field terms
-- (FR1's "element as sub-record"): no opaque function assembles the record.
data ElemBuild = BuildBlocker (KTerm Status) (KTerm Int)

-- | A structural update of one element field (the @sub \@"status" .= t@ form).
newtype ElemUpd = SetStatus (KTerm Status)

evalElem :: Board -> Cmd -> ElemBuild -> BlockerState
evalElem b ci (BuildBlocker st sv) = BlockerState (evalK b ci st) (evalK b ci sv)

-- | Run a structural collection update /forward/ — the spike's analogue of
-- 'Keiki.Core.runUpdate'. This is what makes replay derived (INV1): no
-- hand-written @apply@, just forward re-evaluation of a structural update.
runCUpdate :: Board -> Cmd -> CUpdate -> Board
runCUpdate b ci (CInsert keyT elemB) =
  Map.insert (evalK b ci keyT) (evalElem b ci elemB) b
runCUpdate b ci (CDelete keyT) =
  Map.delete (evalK b ci keyT) b
runCUpdate b ci (CAdjust keyT (SetStatus st)) =
  Map.adjust (\e -> e {bsStatus = evalK b ci st}) (evalK b ci keyT) b

-- ---------------------------------------------------------------------------
-- FR3: structural content guards
-- ---------------------------------------------------------------------------

-- | FR3 collection-content predicates, plus a 'PScalar' stand-in for any
-- ordinary scalar guard (@PEq@\/@PCmp@) that the symbolic layer already handles.
data CPred
  = PMemberC (KTerm BlockerId)
  | PNotMemberC (KTerm BlockerId)
  | PAllC ElemPred
  | PScalar Bool

-- | A bounded element predicate (the body of a @PAll@\/@PAny@).
data ElemPred = StatusIs Status | StatusNot Status

matchElem :: ElemPred -> BlockerState -> Bool
matchElem (StatusIs s) e = bsStatus e == s
matchElem (StatusNot s) e = bsStatus e /= s

evalCPred :: Board -> Cmd -> CPred -> Bool
evalCPred b ci (PMemberC keyT) = Map.member (evalK b ci keyT) b
evalCPred b ci (PNotMemberC keyT) = not (Map.member (evalK b ci keyT) b)
evalCPred b _ (PAllC ep) = all (matchElem ep) (Map.elems b)
evalCPred _ _ (PScalar v) = v

-- ---------------------------------------------------------------------------
-- The BlockerBoard program — authored with ZERO closures
-- ---------------------------------------------------------------------------

-- | One guarded edge: a guard and a structural collection update.
data Edge = Edge {eGuard :: CPred, eUpdate :: CUpdate}

keyOf :: KTerm BlockerId
keyOf = KInpField "id" cId

-- | The BlockerBoard transducer, as a command-dispatched guarded program.
-- Every guard and update is built from structural constructors only — there is
-- not a single 'KClosure' in this definition, which the zero-@TApp@ test checks
-- mechanically.
--
--   * @AddBlocker@: requires the id is /not/ already present, inserts a fresh
--     @Open@ blocker carrying the command's severity.
--   * @ResolveBlocker@ \/ @EscalateBlocker@: require the id /is/ present, adjust
--     that one element's status.
program :: Cmd -> Edge
program (AddBlocker _ _) =
  Edge
    { eGuard = PNotMemberC keyOf,
      eUpdate = CInsert keyOf (BuildBlocker (KLit Open) (KInpField "severity" cSeverity))
    }
program (ResolveBlocker _) =
  Edge {eGuard = PMemberC keyOf, eUpdate = CAdjust keyOf (SetStatus (KLit Resolved))}
program (EscalateBlocker _) =
  Edge {eGuard = PMemberC keyOf, eUpdate = CAdjust keyOf (SetStatus (KLit Escalated))}

-- | "Cannot close the board while any blocker is unresolved" — the lifecycle
-- guard, a @PAll@ over the elements.
closeBoardGuard :: CPred
closeBoardGuard = PAllC (StatusNot Open)

-- | Apply one command through its guarded edge, if the guard holds; otherwise
-- leave the board unchanged (the spike's analogue of @step@ rejecting a
-- command). This is the derived replay step — no hand-written @apply@ (INV1).
stepBoard :: Board -> Cmd -> Board
stepBoard b ci =
  let Edge g u = program ci
   in if evalCPred b ci g then runCUpdate b ci u else b

reconstitute :: [Cmd] -> Board
reconstitute = foldl' stepBoard Map.empty

-- ---------------------------------------------------------------------------
-- Zero-TApp structural walkers
-- ---------------------------------------------------------------------------

termHasClosure :: KTerm a -> Bool
termHasClosure (KLit _) = False
termHasClosure (KInpField _ _) = False
termHasClosure (KLookup _ keyT _ _) = termHasClosure keyT
termHasClosure (KClosure _ _) = True

elemBuildHasClosure :: ElemBuild -> Bool
elemBuildHasClosure (BuildBlocker st sv) = termHasClosure st || termHasClosure sv

updateHasClosure :: CUpdate -> Bool
updateHasClosure (CInsert keyT e) = termHasClosure keyT || elemBuildHasClosure e
updateHasClosure (CDelete keyT) = termHasClosure keyT
updateHasClosure (CAdjust keyT (SetStatus st)) = termHasClosure keyT || termHasClosure st

predHasClosure :: CPred -> Bool
predHasClosure (PMemberC keyT) = termHasClosure keyT
predHasClosure (PNotMemberC keyT) = termHasClosure keyT
predHasClosure (PAllC _) = False
predHasClosure (PScalar _) = False

edgeHasClosure :: Edge -> Bool
edgeHasClosure (Edge g u) = predHasClosure g || updateHasClosure u

-- ---------------------------------------------------------------------------
-- INV2: a model of stepOne's recoverability classification
-- ---------------------------------------------------------------------------

-- | How @solveOutput@\/@stepOne@ treats an output field. Mirrors the verified
-- arms of @stepOne@ in @src\/Keiki\/Core.hs@ (lines ~1349–1359), where every arm
-- returns @Just …@ (never @Nothing@) but the /kind/ of recovery differs:
--
--   * 'FromWire' — @TInpCtorField@: contributes a recovered command slot.
--   * 'FromRegisters' — @TLit@\/@TReg@\/@TLookupField@: @Just []@, deterministically
--     reproducible from the replayed register file. __TLookupField joins here__
--     (INV2): the analysis can see exactly what it reads.
--   * 'OpaqueRecompute' — @TApp1@\/@TApp2@: @Just []@ too, but recompute-and-verify
--     (EP-47) — the closure is analysis-blind. A collection op must never land
--     here.
data Recoverability = FromWire | FromRegisters | OpaqueRecompute
  deriving (Eq, Show)

classify :: KTerm a -> Recoverability
classify (KLit _) = FromRegisters
classify (KInpField _ _) = FromWire
classify (KLookup {}) = FromRegisters
classify (KClosure _ _) = OpaqueRecompute

-- | The @stepOne@ result shape: the recovered command slots (here just slot
-- names). Crucially never @Nothing@ for these arms — a structural lookup does
-- not break the gather the way an opaque term returning @Nothing@ would.
stepOneSlots :: KTerm a -> Maybe [String]
stepOneSlots (KLit _) = Just []
stepOneSlots (KInpField name _) = Just [name]
stepOneSlots (KLookup {}) = Just []
stepOneSlots (KClosure _ _) = Just []

-- ---------------------------------------------------------------------------
-- INV3: a model of checkHiddenInputs over collection updates
-- ---------------------------------------------------------------------------

-- | Does this term read the command (@TInpCtorField@ anywhere)? Mirrors
-- @termReadsInput@ in @src\/Keiki\/Core.hs@.
termReadsInputK :: KTerm a -> Bool
termReadsInputK (KLit _) = False
termReadsInputK (KInpField _ _) = True
termReadsInputK (KLookup _ keyT _ _) = termReadsInputK keyT
termReadsInputK (KClosure _ _) = False

elemBuildReadsInput :: ElemBuild -> Bool
elemBuildReadsInput (BuildBlocker st sv) = termReadsInputK st || termReadsInputK sv

-- | The collection-update extension INV3 asks for: @updateReadsInput@ taught to
-- recurse into the new constructors. A @CInsert@\/@CAdjust@ whose key or element
-- data comes from the command reads the input.
collUpdateReadsInput :: CUpdate -> Bool
collUpdateReadsInput (CInsert keyT e) = termReadsInputK keyT || elemBuildReadsInput e
collUpdateReadsInput (CDelete keyT) = termReadsInputK keyT
collUpdateReadsInput (CAdjust keyT (SetStatus st)) = termReadsInputK keyT || termReadsInputK st

-- | The command-field names a collection update consumes (for coverage).
updateReadSlots :: CUpdate -> [String]
updateReadSlots (CInsert keyT e) = termSlots keyT ++ elemSlots e
updateReadSlots (CDelete keyT) = termSlots keyT
updateReadSlots (CAdjust keyT (SetStatus st)) = termSlots keyT ++ termSlots st

termSlots :: KTerm a -> [String]
termSlots (KLit _) = []
termSlots (KInpField name _) = [name]
termSlots (KLookup _ keyT _ _) = termSlots keyT
termSlots (KClosure _ _) = []

elemSlots :: ElemBuild -> [String]
elemSlots (BuildBlocker st sv) = termSlots st ++ termSlots sv

-- | A miniature of @checkHiddenInputs@ for a single edge. @wireSlots@ is the
-- union of command-field names recovered across the edge's emitted events. The
-- edge is flagged (result 'True') when its update reads the input but that input
-- is not fully covered on the wire — exactly the union-coverage rule, including
-- the ε-edge (empty @wireSlots@) case where any input-reading update is flagged.
checkHiddenEdge :: [String] -> CUpdate -> Bool
checkHiddenEdge wireSlots u =
  collUpdateReadsInput u && not (all (`elem` wireSlots) (updateReadSlots u))

-- ---------------------------------------------------------------------------
-- FR6: Option B — a named, queryable symbolic status
-- ---------------------------------------------------------------------------

-- | The Option B contract: instead of a guard silently becoming an opaque
-- @SBV.free@ Boolean (today's behavior, which a caller /cannot distinguish/ from a
-- real verification), a collection guard yields a named, inspectable status. The
-- scalar part of every aggregate keeps full verification.
--
-- (Option A would instead translate @PMember@\/@PSizeCmp@ to z3 array\/finite-set
-- theory and @PAll@\/@PAny@ to quantifiers — higher value, but the quantifiers
-- risk making the single-valuedness check undecidable\/slow. The Seihou cases need
-- only membership\/emptiness, so Option B is sufficient for the committed
-- consumer; a later EP can upgrade specific forms to Option A.)
data SymStatus = Verified | SkippedCollectionGuard String
  deriving (Eq, Show)

translateOptionB :: CPred -> SymStatus
translateOptionB (PScalar _) = Verified
translateOptionB (PMemberC _) = SkippedCollectionGuard "PMember"
translateOptionB (PNotMemberC _) = SkippedCollectionGuard "PNotMember"
translateOptionB (PAllC _) = SkippedCollectionGuard "PAll"

-- ---------------------------------------------------------------------------
-- INV4: static output arity — output length is a function of the command only
-- ---------------------------------------------------------------------------

-- | The (fixed) number of events each edge emits. Independent of the board, so
-- output arity is static (INV4). A collection mutation is a register update,
-- never a source of per-element output multiplicity.
outputArity :: Cmd -> Int
outputArity (AddBlocker _ _) = 1
outputArity (ResolveBlocker _) = 1
outputArity (EscalateBlocker _) = 1

-- ---------------------------------------------------------------------------
-- The spec
-- ---------------------------------------------------------------------------

-- A finite enumeration of command sequences (the suite is hspec-only; no
-- QuickCheck — see the EP-60 Surprises).
seqs :: [[Cmd]]
seqs =
  [ [],
    [AddBlocker 1 5],
    [AddBlocker 1 5, AddBlocker 2 3],
    [AddBlocker 1 5, ResolveBlocker 1],
    [AddBlocker 1 5, AddBlocker 1 9], -- second Add rejected (already member)
    [ResolveBlocker 7], -- rejected (not a member)
    [AddBlocker 1 5, AddBlocker 2 3, EscalateBlocker 2, ResolveBlocker 1],
    [AddBlocker 1 1, AddBlocker 2 2, AddBlocker 3 3, ResolveBlocker 2, EscalateBlocker 3]
  ]

-- | A reference semantics computed independently of the structural-update
-- machinery, to check INV1's derived replay against (a hand-written oracle used
-- ONLY in the test, never in the library path).
reference :: [Cmd] -> Board
reference = foldl' apply Map.empty
  where
    apply b (AddBlocker i sev)
      | Map.member i b = b
      | otherwise = Map.insert i (BlockerState Open sev) b
    apply b (ResolveBlocker i)
      | Map.member i b = Map.adjust (\e -> e {bsStatus = Resolved}) i b
      | otherwise = b
    apply b (EscalateBlocker i)
      | Map.member i b = Map.adjust (\e -> e {bsStatus = Escalated}) i b
      | otherwise = b

-- | The three edge shapes, one per command constructor.
allCmds :: [Cmd]
allCmds = [AddBlocker 1 5, ResolveBlocker 1, EscalateBlocker 1]

spec :: Spec
spec = do
  describe "FR1/FR2/FR4 — zero-TApp authoring (the headline acceptance)" $ do
    it "the entire BlockerBoard program contains no opaque closure" $
      any (edgeHasClosure . program) allCmds `shouldBe` False
    it "the close-board lifecycle guard (PAll) contains no closure" $
      predHasClosure closeBoardGuard `shouldBe` False

  describe "INV1 — derived replay reconstitutes the correct Map" $
    it "structural forward replay matches the reference oracle on every sequence" $
      map reconstitute seqs `shouldBe` map reference seqs

  describe "INV2 — solveOutput invertibility: TLookupField joins the structural side" $ do
    let priorSeverity = KLookup "blockers" keyOf "severity" bsSeverity
    it "TLookupField classifies as register-recoverable, like a literal read" $ do
      classify priorSeverity `shouldBe` FromRegisters
      classify (KLit (0 :: Int)) `shouldBe` FromRegisters
    it "an opaque closure is the contrasting NON-structural class" $
      classify (KClosure "sz" (const (0 :: Int))) `shouldBe` OpaqueRecompute
    it "a TLookupField output field never breaks the gather (Just, like TReg)" $
      stepOneSlots priorSeverity `shouldBe` Just []
    it "an on-wire input field contributes its recovered slot" $
      stepOneSlots (KInpField "severity" cSeverity) `shouldBe` Just ["severity"]

  describe "INV3 — checkHiddenInputs understands collection updates" $ do
    let addUpd = eUpdate (program (AddBlocker 1 5))
    it "an insert whose element data IS on the wire is clean" $
      checkHiddenEdge ["id", "severity"] addUpd `shouldBe` False
    it "a silent ε-edge insert (no output) whose data reads the input is FLAGGED" $
      checkHiddenEdge [] addUpd `shouldBe` True
    it "an insert that recovers id but NOT severity is flagged (partial coverage)" $
      checkHiddenEdge ["id"] addUpd `shouldBe` True
    it "a delete that only reads the key is clean once the key is on the wire" $
      checkHiddenEdge ["id"] (CDelete keyOf) `shouldBe` False

  describe "INV4 — static output arity (no per-element multiplicity)" $
    it "every edge emits a fixed number of events independent of the board" $
      map outputArity allCmds `shouldBe` [1, 1, 1]

  describe "INV6 — NoThunks: long replay over a strict map yields a forced board" $
    it "reconstituting 2000 commands forces fully (size computable, no bottom)" $ do
      let cmds = [AddBlocker i (i `mod` 7) | i <- [1 .. 2000]]
          b = reconstitute cmds
      Map.size b `shouldBe` 2000
      -- force every element to WHNF; a thunk tower would leak here
      sum (map bsSeverity (Map.elems b)) `shouldBe` sum [i `mod` 7 | i <- [1 .. 2000]]

  describe "FR6 — Option B: honest, queryable symbolic status" $ do
    it "a scalar guard is fully Verified" $
      translateOptionB (PScalar True) `shouldBe` Verified
    it "a PMember collection guard yields a NAMED, queryable skipped status" $
      translateOptionB (PMemberC keyOf) `shouldBe` SkippedCollectionGuard "PMember"
    it "the PAll lifecycle guard is skipped-but-named, never a silent free Bool" $
      translateOptionB closeBoardGuard `shouldBe` SkippedCollectionGuard "PAll"
    it "collection-guarded edges report unverified; the statuses are inspectable" $
      map (translateOptionB . eGuard . program) allCmds
        `shouldBe` [ SkippedCollectionGuard "PNotMember",
                     SkippedCollectionGuard "PMember",
                     SkippedCollectionGuard "PMember"
                   ]

  describe "INV5 — the scalar core path is untouched (real keiki still works)" $
    it "a scalar keiki Term evaluates exactly as before" $
      evalTerm (TLit 42 :: Term '[] () '[] Int) RNil () `shouldBe` 42