packages feed

shikumi-trace 0.2.0.3 → 0.3.0.0

raw patch · 9 files changed

+302/−40 lines, 9 filesdep ~baikaidep ~shikumidep ~shikumi-cachePVP ok

version bump matches the API change (PVP)

Dependency ranges changed: baikai, shikumi, shikumi-cache, shikumi-trace

API changes (from Hackage documentation)

+ Shikumi.Trace: [billingQuality] :: SpanAttrs -> !Maybe UsageRecord
+ Shikumi.Trace: [observedModel] :: SpanAttrs -> !Maybe Text
+ Shikumi.Trace: [transportBilling] :: TraceTree -> !Maybe BillingSummary
+ Shikumi.Trace: attachBillingSummary :: BillingSummary -> TraceTree -> TraceTree
+ Shikumi.Trace.Observation: NodeObservation :: !NodePath -> !Int -> !Maybe Value -> ![(Text, Text)] -> ![(Text, Text)] -> !Maybe Value -> !Either ShikumiError () -> !Bool -> ![(Text, Int)] -> !Bool -> NodeObservation
+ Shikumi.Trace.Observation: [observationEligible] :: NodeObservation -> !Bool
+ Shikumi.Trace.Observation: [observationInputFields] :: NodeObservation -> ![(Text, Text)]
+ Shikumi.Trace.Observation: [observationInput] :: NodeObservation -> !Maybe Value
+ Shikumi.Trace.Observation: [observationInvocation] :: NodeObservation -> !Int
+ Shikumi.Trace.Observation: [observationOpaque] :: NodeObservation -> !Bool
+ Shikumi.Trace.Observation: [observationOutputFields] :: NodeObservation -> ![(Text, Text)]
+ Shikumi.Trace.Observation: [observationOutput] :: NodeObservation -> !Maybe Value
+ Shikumi.Trace.Observation: [observationPath] :: NodeObservation -> !NodePath
+ Shikumi.Trace.Observation: [observationRejectedBy] :: NodeObservation -> ![(Text, Int)]
+ Shikumi.Trace.Observation: [observationStatus] :: NodeObservation -> !Either ShikumiError ()
+ Shikumi.Trace.Observation: data NodeObservation
+ Shikumi.Trace.Observation: instance GHC.Classes.Eq Shikumi.Trace.Observation.NodeObservation
+ Shikumi.Trace.Observation: instance GHC.Internal.Show.Show Shikumi.Trace.Observation.NodeObservation
+ Shikumi.Trace.Observation: runProgramObserved :: forall i o (es :: [Effect]). (LLM :> es, Error ShikumiError :> es, Prim :> es) => Program i o -> i -> Eff es (Either ShikumiError o, [NodeObservation])
+ Shikumi.Trace.Program: walkProgramWith :: forall i o (es :: [Effect]). (LLM :> es, Error ShikumiError :> es) => (forall a. () => SpanKind -> Text -> Eff es a -> Eff es a) -> (forall x y. () => NodePath -> Program x y -> x -> Eff es y) -> Eff es () -> Program i o -> i -> Eff es o
- Shikumi.Trace: SpanAttrs :: !Maybe Text -> !Maybe Text -> !Maybe Value -> !Maybe Value -> !Maybe Integer -> !Maybe Natural -> !Maybe Natural -> !Maybe Scientific -> !Int -> ![ToolCallRecord] -> !Maybe Text -> !Maybe NodePath -> SpanAttrs
+ Shikumi.Trace: SpanAttrs :: !Maybe Text -> !Maybe Text -> !Maybe Value -> !Maybe Value -> !Maybe Integer -> !Maybe Natural -> !Maybe Natural -> !Maybe Scientific -> !Int -> ![ToolCallRecord] -> !Maybe Text -> !Maybe NodePath -> !Maybe UsageRecord -> !Maybe Text -> SpanAttrs
- Shikumi.Trace: TraceTree :: !SpanId -> !Map SpanId Span -> TraceTree
+ Shikumi.Trace: TraceTree :: !SpanId -> !Map SpanId Span -> !Maybe BillingSummary -> TraceTree

Files

CHANGELOG.md view
@@ -2,6 +2,16 @@  ## Unreleased +## 0.3.0.0 — 2026-09-08++- Raise the internal `shikumi` bound to `^>=0.4.0.0` and `shikumi-cache` to `^>=0.2.0.0`.++- Persist optional billing detail and logical span quality in format 3 while reading formats 1 and 2. Streaming terminals now populate usage; observed model identity comes only from evidence. Billing attachment does not add replay entries. Public record additions require PVP major review.++- Upgrade the dependency on `mori://shinzui/baikai/packages/baikai` to `>=0.7.0.0 && <0.8`.++- Add isolated `runProgramObserved` and `NodeObservation`, sharing traced control flow and retaining failed-attempt evidence with rejection lineage. Embed remains opaque; concurrent outer executions use separate storage.+ ## 0.2.0.3 — 2026-08-29  ### Changed
shikumi-trace.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.4 name:            shikumi-trace-version:         0.2.0.3+version:         0.3.0.0 synopsis:   Hierarchical tracing, observability, and deterministic replay for shikumi (EP-7) @@ -44,6 +44,7 @@     Shikumi.Trace.Feedback     Shikumi.Trace.Internal.Spike     Shikumi.Trace.Node+    Shikumi.Trace.Observation     Shikumi.Trace.Program     Shikumi.Trace.Replay     Shikumi.Trace.ResponseJSON@@ -51,7 +52,7 @@    build-depends:     , aeson          >=2.2      && <2.3-    , baikai         >=0.6      && <0.7+    , baikai         >=0.7.0.0  && <0.8     , base           >=4.20     && <5     , bytestring     >=0.11     && <0.13     , containers     >=0.6      && <0.9@@ -60,8 +61,8 @@     , generic-lens   >=2.2      && <2.4     , lens           ^>=5.3     , scientific     >=0.3      && <0.4-    , shikumi        ^>=0.3.0.0-    , shikumi-cache  ^>=0.1.2.0+    , shikumi        ^>=0.4.0.0+    , shikumi-cache  ^>=0.2.0.0     , text           ^>=2.1     , time           >=1.12     && <1.17     , vector         >=0.13     && <0.14@@ -73,7 +74,7 @@   ghc-options:    -threaded -with-rtsopts=-N   build-depends:     , base-    , shikumi-trace  ^>=0.2.0.0+    , shikumi-trace  ^>=0.3.0.0  test-suite shikumi-trace-test   import:         common-options@@ -81,10 +82,13 @@   hs-source-dirs: test   main-is:        Main.hs   ghc-options:    -threaded -with-rtsopts=-N-  other-modules:  TraceFixtures+  other-modules:+    ObservationSpec+    TraceFixtures+   build-depends:     , aeson-    , baikai            >=0.6      && <0.7+    , baikai            >=0.7.0.0  && <0.8     , base     , bytestring     , containers@@ -92,9 +96,9 @@     , generic-lens     , lens              ^>=5.3     , QuickCheck-    , shikumi           ^>=0.3.0.0-    , shikumi-cache     ^>=0.1.2.0-    , shikumi-trace     ^>=0.2.0.0+    , shikumi           ^>=0.4.0.0+    , shikumi-cache     ^>=0.2.0.0+    , shikumi-trace     ^>=0.3.0.0     , tasty     , tasty-hunit     , tasty-quickcheck
src/Shikumi/Trace.hs view
@@ -29,6 +29,7 @@     Span (..),     TraceTree (..),     childrenOf,+    attachBillingSummary,      -- * Node identity (re-exported from "Shikumi.Trace.Node")     NodePath (..),@@ -54,7 +55,9 @@  import Baikai   ( AssistantContent (..),+    AssistantMessageEvent (..),     Context,+    Message (..),     Model,     Options,     Response,@@ -88,6 +91,7 @@ import Shikumi.Cache.Key qualified as Key import Shikumi.Effect.Time (Time, getCurrentTime) import Shikumi.LLM (LLM (..), complete, stream)+import Shikumi.LLM.Observation (BillingSummary, UsageRecord (..), observedModelOf) import Shikumi.Trace.Node (NodePath (..)) import Shikumi.Trace.ResponseJSON () import Text.Read (readMaybe)@@ -138,7 +142,9 @@     -- | the structural path of the @Program@ node that issued this span's LM call     -- (EP-16). Present only on model-call spans produced by @runProgramTraced@;     -- 'Nothing' for spans opened by bare 'withSpan' or a non-node-correlated run.-    nodePath :: !(Maybe NodePath)+    nodePath :: !(Maybe NodePath),+    billingQuality :: !(Maybe UsageRecord),+    observedModel :: !(Maybe Text)   }   deriving stock (Eq, Show, Generic)   deriving anyclass (ToJSON, FromJSON)@@ -158,7 +164,9 @@       retries = 0,       toolCalls = [],       cacheKey = Nothing,-      nodePath = Nothing+      nodePath = Nothing,+      billingQuality = Nothing,+      observedModel = Nothing     }  -- | One node of the trace tree.@@ -178,11 +186,16 @@ -- reconstructed from each span's 'parent' pointer ('childrenOf'). data TraceTree = TraceTree   { root :: !SpanId,-    spans :: !(Map SpanId Span)+    spans :: !(Map SpanId Span),+    transportBilling :: !(Maybe BillingSummary)   }   deriving stock (Eq, Show, Generic)   deriving anyclass (ToJSON, FromJSON) +-- | Attach a run-local bounded transport view, without structural attribution.+attachBillingSummary :: BillingSummary -> TraceTree -> TraceTree+attachBillingSummary b t = t {transportBilling = Just b}+ -- | The children of a span, in creation order (sorted by start time, ties broken -- by numeric @span-N@ id when possible). childrenOf :: TraceTree -> SpanId -> [SpanId]@@ -331,7 +344,7 @@ freezeTree st = do   sp <- readIORef (st ^. #spans)   r <- readIORef (st ^. #root)-  pure (TraceTree (fromMaybe (SpanId "") r) sp)+  pure (TraceTree (fromMaybe (SpanId "") r) sp Nothing)  safeHead :: [a] -> Maybe a safeHead [] = Nothing@@ -343,9 +356,8 @@  -- | Capture every LM call as a leaf 'LlmCallSpan' under the active span. Interpose -- on EP-1's @LLM@ effect: open a span, delegate to the underlying handler, then--- fill the span's attributes from the returned 'Response' (and the request). The--- streaming op is wrapped in a span but its attributes are left empty (streams--- carry the same data incrementally; the demo/replay path uses 'complete').+-- fill the span's attributes from the returned 'Response' (and the request). Streaming terminals contribute usage and observed identity; only successful+-- blocking responses produce replay records. -- -- This capture uses 'runTrace'\'s sequential span stack. Compose it with -- 'Shikumi.Program.runProgram'; concurrent program execution is intentionally@@ -356,7 +368,22 @@     resp <- complete m c o     annotateSpan (const (llmAttrs m c o resp))     pure resp-  Stream m c o -> withSpan LlmCallSpan (llmLabel m) (stream m c o)+  Stream m c o -> withSpan LlmCallSpan (llmLabel m) $ do+    evs <- stream m c o+    case [t | EventDone t <- evs] of+      t : _ -> case t ^. #message of+        AssistantMessage p -> annotateSpan $ \a ->+          a+            & #model ?~ (m ^. #modelId)+            & #provider ?~ (m ^. #provider)+            & #inputTokens ?~ (p ^. #usage . #inputTokens)+            & #outputTokens ?~ (p ^. #usage . #outputTokens)+            & #costUsd ?~ realToFrac (p ^. #usage . #cost . #usd :: Rational)+            & #billingQuality ?~ UsageRecord (p ^. #usage)+            & #observedModel .~ observedModelOf (t ^. #evidence)+        _ -> pure ()+      [] -> pure ()+    pure evs  -- | The label for an LM-call span: @provider/model-id@. llmLabel :: Model -> Text@@ -374,6 +401,8 @@     & #inputTokens ?~ (resp ^. #message . #usage . #inputTokens)     & #outputTokens ?~ (resp ^. #message . #usage . #outputTokens)     & #costUsd ?~ realToFrac (resp ^. #message . #usage . #cost . #usd :: Rational)+    & #billingQuality ?~ UsageRecord (resp ^. #message . #usage)+    & #observedModel .~ observedModelOf (resp ^. #evidence)     & #toolCalls .~ toolCallsOf resp     & #cacheKey ?~ unCacheKey (Key.cacheKey m c o) 
src/Shikumi/Trace/Node.hs view
@@ -34,6 +34,7 @@         Map,         Parallel,         Predict,+        PredictCaptured,         Retry,         RetryWhen,         Validate@@ -86,6 +87,7 @@   where     -- @prefix@ is the reversed list of steps taken so far; we reverse on emit.     go :: forall x y. [NodeStep] -> Program x y -> [NodePath]+    go prefix (PredictCaptured _ _ _) = [NodePath (reverse prefix)]     go prefix (Predict _ _) = [NodePath (reverse prefix)]     go prefix (Compose a b) = go (StepComposeL : prefix) a ++ go (StepComposeR : prefix) b     go prefix (FMap _ p) = go (StepFMap : prefix) p
+ src/Shikumi/Trace/Observation.hs view
@@ -0,0 +1,105 @@+{-# LANGUAGE GADTs #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | Structured evidence from one sequential execution, isolated from other runs.+module Shikumi.Trace.Observation (NodeObservation (..), runProgramObserved) where++import Data.Aeson (Value)+import Data.Either (isRight)+import Data.Text (Text)+import Effectful (Eff, (:>))+import Effectful.Error.Static (Error, catchError, throwError)+import Effectful.Prim (Prim)+import Effectful.Prim.IORef (modifyIORef', newIORef, readIORef)+import Shikumi.Adapter (ToPrompt (..))+import Shikumi.Error (ShikumiError)+import Shikumi.LLM (LLM)+import Shikumi.Program (CaptureCodec (..), Program (..), runProgram)+import Shikumi.Trace (SpanKind)+import Shikumi.Trace.Node (NodePath)+import Shikumi.Trace.Program (walkProgramWith)++-- | Invocation numbers start at zero per example, across all leaves. Rejection+-- lineage records the enclosing scope label and its first invocation number.+-- Opaque Embed observations never claim to identify internal predictors.+data NodeObservation = NodeObservation+  { observationPath :: !NodePath,+    observationInvocation :: !Int,+    observationInput :: !(Maybe Value),+    observationInputFields :: ![(Text, Text)],+    observationOutputFields :: ![(Text, Text)],+    observationOutput :: !(Maybe Value),+    observationStatus :: !(Either ShikumiError ()),+    observationEligible :: !Bool,+    observationRejectedBy :: ![(Text, Int)],+    observationOpaque :: !Bool+  }+  deriving stock (Eq, Show)++-- | Typed failures retain observations. Host exceptions and cancellation escape.+-- Uses private Prim cells, so concurrent callers never exchange evidence.+runProgramObserved ::+  forall i o es.+  (LLM :> es, Error ShikumiError :> es, Prim :> es) =>+  Program i o -> i -> Eff es (Either ShikumiError o, [NodeObservation])+runProgramObserved program input = do+  observations <- newIORef []+  next <- newIORef 0+  let scope :: forall a. SpanKind -> Text -> Eff es a -> Eff es a+      scope _ label action = do+        start <- readIORef next+        action `catchError` \_ (err :: ShikumiError) -> do+          modifyIORef'+            observations+            ( map+                ( \obs ->+                    if observationInvocation obs >= start+                      then obs {observationEligible = False, observationRejectedBy = (label, start) : observationRejectedBy obs}+                      else obs+                )+            )+          throwError err+      record ::+        forall x y.+        NodePath ->+        Bool ->+        (x -> Maybe Value) ->+        (y -> Maybe Value) ->+        (x -> [(Text, Text)]) ->+        (y -> [(Text, Text)]) ->+        Program x y ->+        x ->+        Eff es y+      record path opaque encodeIn encodeOut fieldsIn fieldsOut node x = do+        ordinal <- readIORef next+        modifyIORef' next (+ 1)+        result <- (Right <$> runProgram node x) `catchError` \_ err -> pure (Left err)+        let obs =+              NodeObservation+                path+                ordinal+                (encodeIn x)+                (fieldsIn x)+                (either (const []) fieldsOut result)+                (either (const Nothing) encodeOut result)+                (() <$ result)+                (isRight result && not opaque)+                []+                opaque+        modifyIORef' observations (obs :)+        either throwError pure result+      leaf :: forall x y. NodePath -> Program x y -> x -> Eff es y+      leaf path node@(PredictCaptured codec _ _) =+        record+          path+          False+          (Just . encodeCaptureInput codec)+          (Just . encodeCaptureOutput codec)+          toPromptFields+          toPromptFields+          node+      leaf path node@(Predict _ _) = record path False (const Nothing) (const Nothing) toPromptFields toPromptFields node+      leaf path node = record path True (const Nothing) (const Nothing) (const []) (const []) node+  result <- (Right <$> walkProgramWith scope leaf (pure ()) program input) `catchError` \_ err -> pure (Left err)+  obs <- reverse <$> readIORef observations+  pure (result, obs)
src/Shikumi/Trace/Program.hs view
@@ -39,11 +39,13 @@     -- * Node-aware capture and execution     tracedNodeLLM,     runProgramTraced,+    walkProgramWith,   ) where  import Control.Lens ((&), (.~)) import Data.Generics.Labels ()+import Data.Text (Text) import Effectful (Dispatch (Dynamic), DispatchOf, Eff, Effect, (:>)) import Effectful.Dispatch.Dynamic (interpose, interpret, localSeqUnlift, send) import Effectful.Error.Static (Error, catchError, throwError)@@ -62,6 +64,7 @@         Map,         Parallel,         Predict,+        PredictCaptured,         Retry,         RetryWhen,         Validate@@ -161,46 +164,65 @@   Program i o ->   i ->   Eff es o-runProgramTraced = go []+runProgramTraced = walkProgramWith withSpan leaf bumpRetry   where+    leaf _ node@(Embed _) i = runProgram node i+    leaf path node i = localNode path (runProgram node i)++-- | Shared sequential control flow. A scope callback may retain failure lineage;+-- the leaf callback handles predictions and opaque Embed executions.+walkProgramWith ::+  forall i o es.+  (LLM :> es, Error ShikumiError :> es) =>+  (forall a. SpanKind -> Text -> Eff es a -> Eff es a) ->+  (forall x y. NodePath -> Program x y -> x -> Eff es y) ->+  Eff es () ->+  Program i o ->+  i ->+  Eff es o+walkProgramWith scope leaf onRetry = go []+  where     go :: forall x y. [NodeStep] -> Program x y -> x -> Eff es y+    go prefix node@(PredictCaptured _ _ _) i =+      scope ModuleSpan "Predict" (leaf (NodePath (reverse prefix)) node i)     go prefix node@(Predict _ _) i =-      withSpan ModuleSpan "Predict" (localNode (NodePath (reverse prefix)) (runProgram node i))+      scope ModuleSpan "Predict" (leaf (NodePath (reverse prefix)) node i)     go prefix (Compose f g) i =-      withSpan CombinatorSpan "Compose" (go (StepComposeL : prefix) f i >>= go (StepComposeR : prefix) g)+      scope CombinatorSpan "Compose" (go (StepComposeL : prefix) f i >>= go (StepComposeR : prefix) g)     go prefix (FMap k p) i =-      withSpan CombinatorSpan "FMap" (k <$> go (StepFMap : prefix) p i)+      scope CombinatorSpan "FMap" (k <$> go (StepFMap : prefix) p i)     go prefix (Map _ p) xs =-      withSpan CombinatorSpan "Map" (traverse (go (StepMap : prefix) p) xs)+      scope CombinatorSpan "Map" (traverse (go (StepMap : prefix) p) xs)     go prefix (Parallel a b) i =-      withSpan CombinatorSpan "Parallel" ((,) <$> go (StepParallelL : prefix) a i <*> go (StepParallelR : prefix) b i)+      scope CombinatorSpan "Parallel" ((,) <$> go (StepParallelL : prefix) a i <*> go (StepParallelR : prefix) b i)     go prefix (Retry n p) i =-      withSpan CombinatorSpan "Retry" (tracedRetry (go (StepRetry : prefix)) (const True) n p i)+      scope CombinatorSpan "Retry" (tracedRetry onRetry (go (StepRetry : prefix)) (const True) n p i)     go prefix (RetryWhen ok n p) i =-      withSpan CombinatorSpan "RetryWhen" (tracedRetry (go (StepRetryWhen : prefix)) ok n p i)+      scope CombinatorSpan "RetryWhen" (tracedRetry onRetry (go (StepRetryWhen : prefix)) ok n p i)     go prefix (Validate v p) i =-      withSpan CombinatorSpan "Validate" (go (StepValidate : prefix) p i >>= acceptOrReject v)+      scope CombinatorSpan "Validate" (go (StepValidate : prefix) p i >>= acceptOrReject v)     go prefix (MajorityVote k sched reduce p) i =-      withSpan CombinatorSpan "MajorityVote" $+      scope CombinatorSpan "MajorityVote" $         reduce <$> traverse (\mt -> withSampleTemp mt (go (StepMajorityVote : prefix) p i)) (sampleTemps k sched)     go prefix (Ensemble ps reduce) i =-      withSpan CombinatorSpan "Ensemble" $+      scope CombinatorSpan "Ensemble" $         reduce <$> sequence [go (StepEnsemble idx : prefix) p i | (idx, p) <- zip [0 ..] ps]-    go _ (Embed f) i =-      withSpan CombinatorSpan "Embed" (f i)+    go prefix node@(Embed _) i =+      scope CombinatorSpan "Embed" (leaf (NodePath (reverse prefix)) node i)  tracedRetry ::-  (Trace :> es, Error ShikumiError :> es) =>+  (Error ShikumiError :> es) =>+  Eff es () ->   (Program x y -> x -> Eff es y) ->   (ShikumiError -> Bool) ->   Int ->   Program x y ->   x ->   Eff es y-tracedRetry run ok n p i = attempt (max 1 n)+tracedRetry onRetry run ok n p i = attempt (max 1 n)   where     attempt left =       run p i `catchError` \_cs e ->         if ok e && left > 1-          then bumpRetry >> attempt (left - 1)+          then onRetry >> attempt (left - 1)           else throwError e
src/Shikumi/Trace/Store.hs view
@@ -49,9 +49,9 @@   deriving anyclass (ToJSON, FromJSON)  -- | The trace-file schema version this build reads and writes. Bumped 1→2 in--- EP-16 for the additive optional @SpanAttrs.nodePath@ field.+-- EP-16 for optional node paths; 2→3 adds optional billing quality and run attempts. currentFormatVersion :: Int-currentFormatVersion = 2+currentFormatVersion = 3  -- | The oldest trace format this build still reads. v1→v2 was additive (the -- optional @SpanAttrs.nodePath@ field), so v1 files decode without migration.
test/Main.hs view
@@ -19,6 +19,7 @@ import Effectful.Error.Static (runErrorNoCallStack) import Effectful.Prim (runPrim) import GHC.Generics (Generic)+import ObservationSpec qualified import Shikumi.Adapter (ToPrompt) import Shikumi.Cache.Key (CacheKey (..)) import Shikumi.Cache.Key qualified as Key@@ -100,7 +101,7 @@   defaultMain $     testGroup       "shikumi-trace"-      [spikeTests, treeTests, storeTests, replayTests, e2eTests, nodeTests, correlateTests, feedbackTests]+      [ObservationSpec.tests, spikeTests, treeTests, storeTests, replayTests, e2eTests, nodeTests, correlateTests, feedbackTests]  -- --------------------------------------------------------------------------- -- M0@@ -197,6 +198,16 @@           BL.writeFile p (encode (TraceFile 1 tree))           res <- readTraceFile p           res @?= Right tree,+      testCase "formatVersion 2 without billing fields remains readable" $+        withSystemTempDirectory "shikumi-trace" $ \dir -> do+          let p = dir <> "/v2.json"+          BL.writeFile p "{\"formatVersion\":2,\"tree\":{\"root\":\"span-0\",\"spans\":{\"span-0\":{\"spanId\":\"span-0\",\"parent\":null,\"kind\":\"ProgramSpan\",\"label\":\"legacy\",\"startedAt\":\"2026-09-08T00:00:00Z\",\"endedAt\":null,\"attrs\":{\"model\":null,\"provider\":null,\"prompt\":null,\"response\":null,\"latencyMs\":null,\"inputTokens\":null,\"outputTokens\":null,\"costUsd\":null,\"retries\":0,\"toolCalls\":[],\"cacheKey\":null,\"nodePath\":null}}}}}"+          res <- readTraceFile p+          case res of+            Left err -> assertFailure (T.unpack err)+            Right tree -> do+              transportBilling tree @?= Nothing+              map attrs (Map.elems (spans tree)) @?= [emptyAttrs],       testCase "replayIndex maps each llm-call cacheKey to its response" $ do         tree <- buildTree         idx <- replayIndexOrFail tree@@ -408,7 +419,7 @@                 }           }       ss = [rootSpan, child 1 firstResp, child 2 secondResp]-   in TraceTree (SpanId "span-0") (Map.fromList [(spanId s, s) | s <- ss])+   in TraceTree (SpanId "span-0") (Map.fromList [(spanId s, s) | s <- ss]) Nothing  numericSiblingTree :: TraceTree numericSiblingTree =@@ -433,7 +444,7 @@             attrs = emptyAttrs           }       ss = rootSpan : map child ([2 .. 12] :: [Int])-   in TraceTree (SpanId "span-1") (Map.fromList [(spanId s, s) | s <- ss])+   in TraceTree (SpanId "span-1") (Map.fromList [(spanId s, s) | s <- ss]) Nothing  -- --------------------------------------------------------------------------- -- A small generator of random trees for the round-trip property@@ -465,7 +476,7 @@   let (ss, _) = flattenShape Nothing 0 shape       m = Map.fromList [(spanId s, s) | s <- ss]       rootId = spanId (firstSpan ss)-  pure (TraceTree rootId m)+  pure (TraceTree rootId m Nothing)   where     firstSpan (s : _) = s     firstSpan [] = error "genTree: empty"
+ test/ObservationSpec.hs view
@@ -0,0 +1,79 @@+module ObservationSpec (tests) where++import Data.Aeson (ToJSON, toJSON)+import Data.IORef (newIORef)+import Data.Text (Text)+import Effectful (liftIO, runEff)+import Effectful.Concurrent (runConcurrent)+import Effectful.Concurrent.Async (mapConcurrently)+import Effectful.Error.Static (runErrorNoCallStack)+import Effectful.Prim (runPrim)+import GHC.Generics (Generic)+import Shikumi.Adapter (ToPrompt)+import Shikumi.Error (ShikumiError)+import Shikumi.Module (predict, predictCaptured)+import Shikumi.Program (Program (..))+import Shikumi.Schema (FromModel, ToSchema, Validatable)+import Shikumi.Signature (mkSignature)+import Shikumi.Trace.Observation+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertFailure, testCase, (@?=))+import TraceFixtures (mkResponse, runSequencedLLM)++newtype Cell = Cell {cell :: Text} deriving stock (Eq, Show, Generic)++instance ToJSON Cell++instance ToSchema Cell++instance FromModel Cell++instance ToPrompt Cell++instance Validatable Cell++leaf :: Program Cell Cell+leaf = predictCaptured (mkSignature "Echo")++run :: Program i o -> i -> [Text] -> IO (Either ShikumiError (Either ShikumiError o, [NodeObservation]))+run p i replies = do+  ref <- newIORef (map mkResponse replies)+  runEff . runPrim . runErrorNoCallStack @ShikumiError . runSequencedLLM ref $ runProgramObserved p i++tests :: TestTree+tests =+  testGroup+    "observations"+    [ testCase "concurrent outer examples retain independent inputs and ordinals" $ do+        results <-+          runEff . runConcurrent $+            mapConcurrently+              (\value -> liftIO (run leaf (Cell value) ["{\"cell\":\"ok\"}"]))+              ["one", "two"]+        let observations = [obs | Right (_, [obs]) <- results]+        map observationInput observations @?= map (Just . toJSON . Cell) ["one", "two"]+        map observationInvocation observations @?= [0, 0],+      testCase "retry keeps evidence but rejects successful leaves in failed validation" $ do+        result <- run (Retry 2 (Validate (\x -> if x == Cell "bad" then Left "reject" else Right x) leaf)) (Cell "input") ["{\"cell\":\"bad\"}", "{\"cell\":\"good\"}"]+        case result of+          Right (out, obs) -> do+            out @?= Right (Cell "good")+            map observationInvocation obs @?= [0, 1]+            map observationEligible obs @?= [False, True]+            map observationOutput obs @?= map (Just . toJSON . Cell) ["bad", "good"]+          Left e -> assertFailure (show e),+      testCase "map repeats a structural path with distinct invocations" $ do+        result <- run (Map 2 leaf) [Cell "a", Cell "b"] ["{\"cell\":\"a\"}", "{\"cell\":\"b\"}"]+        case result of+          Right (_, [a, b]) -> do+            observationPath a @?= observationPath b+            map observationInvocation [a, b] @?= [0, 1]+          other -> assertFailure (show other),+      testCase "root failure returns evidence; missing codec is distinct from failure" $ do+        result <- run (predict (mkSignature "Echo") :: Program Cell Cell) (Cell "a") ["broken"]+        case result of+          Right (Left _, [obs]) -> do+            observationInput obs @?= Nothing+            observationEligible obs @?= False+          other -> assertFailure (show other)+    ]