packages feed

ppad-censor 0.5.2 → 0.6.0

raw patch · 8 files changed

+718/−174 lines, 8 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ Censor: Split :: !a -> !a -> !a -> Split a
+ Censor: SplitMeter :: (Int -> IO () -> IO (Word64, Word64)) -> SplitMeter
+ Censor: [measureSplit] :: SplitMeter -> Int -> IO () -> IO (Word64, Word64)
+ Censor: [splitCycles] :: Split a -> !a
+ Censor: [splitPeriod] :: Split a -> !a
+ Censor: [splitWall] :: Split a -> !a
+ Censor: attributeSplit :: SplitMeter -> Config -> Hypothesis a -> IO (Split Attribution)
+ Censor: data Split a
+ Censor: instance GHC.Base.Functor Censor.Split
+ Censor: instance GHC.Classes.Eq a => GHC.Classes.Eq (Censor.Split a)
+ Censor: instance GHC.Show.Show a => GHC.Show.Show (Censor.Split a)
+ Censor: newtype SplitMeter
+ Censor: runSplit :: SplitMeter -> Config -> Hypothesis a -> IO (Split Result)
+ Censor: runSplitWith :: SplitMeter -> Config -> Hypothesis a -> (Frame -> IO ()) -> IO (Split Result)
+ Censor: withSplitMeter :: (SplitMeter -> IO a) -> IO a
+ Censor.Meter: SplitMeter :: (Int -> IO () -> IO (Word64, Word64)) -> SplitMeter
+ Censor.Meter: [measureSplit] :: SplitMeter -> Int -> IO () -> IO (Word64, Word64)
+ Censor.Meter: newtype SplitMeter
+ Censor.Meter: withSplitMeter :: (SplitMeter -> IO a) -> IO a
+ Censor.Runner: splitMeterName :: String
+ Censor.Runner: withSplitMeterArg :: (SplitMeter -> IO a) -> IO a
+ Censor.Runner.Report: ChannelReport :: !String -> !String -> !Result -> !Maybe Attribution -> ChannelReport
+ Censor.Runner.Report: [chAttribution] :: ChannelReport -> !Maybe Attribution
+ Censor.Runner.Report: [chName] :: ChannelReport -> !String
+ Censor.Runner.Report: [chResult] :: ChannelReport -> !Result
+ Censor.Runner.Report: [chUnit] :: ChannelReport -> !String
+ Censor.Runner.Report: [crChannels] :: CaseReport -> ![ChannelReport]
+ Censor.Runner.Report: data ChannelReport
+ Censor.Runner.Report: instance GHC.Show.Show Censor.Runner.Report.ChannelReport
- Censor.Runner.Report: CaseReport :: !String -> !Result -> !Maybe Attribution -> !Maybe Int -> !Maybe Noise -> !Maybe Noise -> !Maybe [Frame] -> CaseReport
+ Censor.Runner.Report: CaseReport :: !String -> !Result -> !Maybe Attribution -> !Maybe Int -> !Maybe Noise -> !Maybe Noise -> !Maybe [Frame] -> ![ChannelReport] -> CaseReport

Files

CHANGELOG view
@@ -1,5 +1,33 @@ # Changelog +- 0.6.0 (2026-10-16)+  * **Split meter.** `withSplitMeter` (Linux) reads the cycle counter+    and the wall clock over the same execution, and `runSplit` /+    `runSplitWith` test three channels on every pair: cycles, the+    clock period (wall time per cycle, in femtoseconds), and wall+    time. Each channel is a full hedge with its own warmup+    calibration and its own guarantee at `cfgAlpha`; the cycles+    channel stops the run, and its result is the verdict.+    `attributeSplit` runs the A/A and B/B controls once and reads a+    verdict per channel. A secret can move wall time through cycles+    (the code) or through the clock (data-dependent power under+    frequency scaling); the split tells the two apart on the same+    samples.++  * `runTraceSuite` accepts the meter `cycles+wall`+    (`Censor.Runner.splitMeterName`). The case's `result` is the+    cycles channel, and a new per-case `channels` array in the+    report-v3 JSON carries the period and wall channels, each with+    its unit and, on a rejection, its own controls. The controls run+    when any channel rejects. Single-meter reports are unchanged.++  * **Breaking:** `CaseReport` gains `crChannels` (empty under a+    single meter), with `ChannelReport` for its entries.++  * The driver's per-stream state is factored into an internal+    channel shared by `runCTWith` and the split driver. Results are+    unchanged: pinned-seed runs reproduce exactly.+ - 0.5.2 (2026-10-11)   * Supports GHC 9.8, via ppad-eproc 0.5.1. The package description     no longer renders "A/A and B/B" as emphasis on Hackage.
lib/Censor.hs view
@@ -78,6 +78,12 @@   , Frame(..)   , runCTAttributed +    -- * Split runs+  , Split(..)+  , runSplit+  , runSplitWith+  , attributeSplit+     -- * Batch calibration   , calibrateBatch   , CalibReport(..)@@ -102,6 +108,8 @@   , Counter(..)   , MeterError(..)   , withCounter+  , SplitMeter(..)+  , withSplitMeter      -- * Sampler support (re-exports)   , Rng@@ -583,73 +591,25 @@   validateConfig cfg   (!root, !wSeed, !mSeed) <- resolveSeeds (cfgSeed cfg)   let !samplers = orderedSamplers hyp-  (!c, !qs, !meterResolves, !medR) <- warmup meter cfg hyp samplers wSeed-  unless meterResolves $ throwIO WarmupZeroDuration-  !mdelta <- case cfgMargin cfg of-    Nothing  -> pure Nothing-    Just rel -> do-      let !delta = rel * medR-      unless (delta > 0) $ throwIO $! InvalidConfig-        "cfgMargin resolves to a zero absolute margin (warmup \-        \median reading is 0); raise cfgBatch"-      unless (delta < c) $ throwIO $! InvalidConfig-        "cfgMargin resolves to an absolute margin at or above the \-        \warmup clip bound; lower cfgMargin (the tolerance exceeds \-        \the pair-noise scale the test bets against)"-      pure (Just delta)-  !hcfg <- case mkHedgeCfg (cfgAlpha cfg) c qs mdelta of-    Left  e -> throwIO (InvalidEprocConfig e)-    Right x -> pure x-  !cscfg <- case EPCS.config (negate (hcC3 hcfg)) (hcC3 hcfg)-                   (cfgAlpha cfg) effectGrid of-    Left  e -> throwIO (InvalidEprocConfig e)-    Right x -> pure x-  let !mixc = hcMix hcfg-      finish ctor !n !st !iv !cl = ctor-        n (EPMix.log_evalue_sup mixc (hsMix st))-        (EPMix.p_value mixc (hsMix st))-        iv-        (clAtC cl) (clAt4C cl) (clAt16C cl) c-        (EPSign.log_evalue_sup (hsSign st))-        (EPMagn.log_evalue_sup (hsMagn1 st))-        (EPMagn.log_evalue_sup (hsMagn2 st))-        (EPMagn.log_evalue_sup (hsMagn3 st))-        (cdfPeaks qs (hsCdf st))-        root-        mdelta-      -- the effect interval is latched: once the evidence pins the-      -- mean below the grid's resolution the survivor set empties,-      -- and the last resolvable interval (nested within all earlier-      -- ones, so still covered by the time-uniform guarantee) is-      -- what finish reports.-      go !n !st !cs !iv !rng !cl-        | n >= cfgBudget cfg =-            pure $! finish Pass n st iv cl+      pair      = runPair meter cfg hyp samplers+  ws  <- warmup cfg pair wSeed+  !ch <- channel cfg [ (fromIntegral x, fromIntegral y) | (x, y) <- ws ]+  let go !n !st !rng+        | n >= cfgBudget cfg = pure $! chanResult ch root n st         | otherwise = do             let (!rng', !bit) = nextOrderBit rng-            (!ta, !tb) <- runPair meter cfg hyp samplers bit-            let !xa      = fromIntegral ta :: Double-                !xb      = fromIntegral tb :: Double-                !d       = xa - xb-                !clipped = abs d > c-                !st'     = updateHedge hcfg st d xa xb-                !cs'     = EPCS.update cscfg cs (clipD (hcC3 hcfg) d)-                !iv'     = case EPCS.interval cscfg cs' of-                             Just x  -> x-                             Nothing -> iv-                !cl'     = bumpClips hcfg cl d-                !n'      = n + 1+            (!ta, !tb) <- pair bit+            let !xa  = fromIntegral ta :: Double+                !xb  = fromIntegral tb :: Double+                !st' = stepChan ch st xa xb+                !n'  = n + 1             -- lazy: the frame's fields (mixture reads) are forced             -- only if the observer forces them, so runCT pays nothing.-            observe (Frame n' d-                       (EPMix.log_evalue mixc (hsMix st'))-                       (EPMix.log_evalue_sup mixc (hsMix st'))-                       (fst iv') (snd iv') clipped)-            case EPMix.decide mixc (hsMix st') of-              EPMix.Reject -> pure $! finish Reject n' st' iv' cl'-              _            -> go n' st' cs' iv' rng' cl'-      !iv0 = (negate (hcC3 hcfg), hcC3 hcfg)-  go 0 (initialHedge hcfg) (EPCS.initial cscfg) iv0 mSeed noClips+            observe (chanFrame ch n' (xa - xb) st')+            if chanRejects ch st'+              then pure $! chanResult ch root n' st'+              else go n' st' rng'+  go 0 (chanInitial ch) mSeed  -- | Run a constant-time test. See 'runCTWith' for the phase-by-phase --   description; this is that driver with the per-pair observer@@ -673,6 +633,99 @@       pure (r, Just att)     Pass{}   -> pure (r, Nothing) +-- | One value per channel of a split run ('runSplitWith'): a+--   t'Result' each, or the controls' t'Attribution' each.+data Split a = Split+  { splitCycles :: !a+    -- ^ the cycle count: whether the code's execution depends on+    --   the class. The verdict is this channel's.+  , splitPeriod :: !a+    -- ^ wall time per cycle, in femtoseconds: whether the clock the+    --   cycles ran at does.+  , splitWall   :: !a+    -- ^ wall time, in nanoseconds: the two together, as elapsed+    --   time shows them to an observer.+  } deriving (Eq, Show)++instance Functor Split where+  fmap f (Split c p w) = Split (f c) (f p) (f w)++-- | Run a constant-time test under a t'SplitMeter', handing the+--   cycles channel's t'Frame' to the observer after every+--   main-phase pair.+--+--   Every pair is read once, as cycles and wall time, and tested on+--   three channels: cycles, the clock period (wall time per cycle),+--   and wall time. Each channel is a full test as 'runCTWith'+--   describes, with its own clip bound and cut points fixed by the+--   same warmup pairs, and each holds its own type-I error at+--   'cfgAlpha'. The cycles channel decides when the run stops: on+--   its rejection, or at the budget. That stop is a stopping time+--   for the readings of all three, and the order bit is independent+--   of all three, so the other two channels' guarantees hold where+--   it falls.+--+--   The cycles channel answers the constant-time question. The+--   period channel answers whether the clock moved with the class,+--   as it does when the core's frequency follows data-dependent+--   power; wall time answers whether elapsed time moved, by either+--   route. A wall rejection beside a cycles pass, with the period+--   rejecting too, is a frequency effect rather than a code path.+--+--   The period reads cleanly only beside a cycles pass: the counted+--   region carries a fixed overhead (the clock reads), so wall time+--   per cycle falls as cycles grow, and a cycles difference+--   registers on the period as well.+runSplitWith+  :: SplitMeter -> Config -> Hypothesis a -> (Frame -> IO ())+  -> IO (Split Result)+runSplitWith meter cfg hyp observe = do+  validateConfig cfg+  (!root, !wSeed, !mSeed) <- resolveSeeds (cfgSeed cfg)+  let !samplers = orderedSamplers hyp+      pair      = runSplitPair meter cfg hyp samplers+  ws <- warmup cfg pair wSeed+  let rs = fmap splitReadings ws+  !cc <- channel cfg (fmap splitCycles rs)+  !cp <- channel cfg (fmap splitPeriod rs)+  !cw <- channel cfg (fmap splitWall rs)+  let finish !n !sc !sp !sw = Split+        (chanResult cc root n sc)+        (chanResult cp root n sp)+        (chanResult cw root n sw)+      go !n !sc !sp !sw !rng+        | n >= cfgBudget cfg = pure $! finish n sc sp sw+        | otherwise = do+            let (!rng', !bit) = nextOrderBit rng+            !r <- pair bit+            case splitReadings r of+              Split (!ca, !cb) (!pa, !pb) (!wa, !wb) -> do+                let !sc' = stepChan cc sc ca cb+                    !sp' = stepChan cp sp pa pb+                    !sw' = stepChan cw sw wa wb+                    !n'  = n + 1+                observe (chanFrame cc n' (ca - cb) sc')+                if chanRejects cc sc'+                  then pure $! finish n' sc' sp' sw'+                  else go n' sc' sp' sw' rng'+  go 0 (chanInitial cc) (chanInitial cp) (chanInitial cw) mSeed++-- | 'runSplitWith' with the per-pair observer discarded.+runSplit :: SplitMeter -> Config -> Hypothesis a -> IO (Split Result)+runSplit meter cfg hyp = runSplitWith meter cfg hyp (\_ -> pure ())++-- | The A\/A and B\/B controls under a t'SplitMeter': each control+--   is one split run, and each channel's verdict comes from that+--   channel's readings of the two. Only meaningful for a channel+--   that rejected, as for 'attribute'.+attributeSplit+  :: SplitMeter -> Config -> Hypothesis a -> IO (Split Attribution)+attributeSplit meter cfg hyp = do+  Split ac ap aw <- runSplit meter cfg (toAA hyp)+  Split bc bp bw <- runSplit meter cfg (toBB hyp)+  pure $! Split (attribution ac bc) (attribution ap bp)+                (attribution aw bw)+ -- | Pick a 'cfgBatch' that lifts the per-batch reading well above the --   meter's resolution: probe a class-A sample at geometrically --   growing batches until the reading reaches a target magnitude,@@ -895,16 +948,21 @@ attribute meter cfg hyp = do   raa <- runAA meter cfg hyp   rbb <- runBB meter cfg hyp+  pure $! attribution raa rbb++-- the controls' combined verdict, carrying both runs.+attribution :: Result -> Result -> Attribution+attribution raa rbb =   let !v = case (raa, rbb) of         (Pass{},   Pass{})   -> ControlsPass         (Reject{}, Pass{})   -> SampleAAsymmetry         (Pass{},   Reject{}) -> SampleBAsymmetry         (Reject{}, Reject{}) -> PervasiveAsymmetry-  pure $! Attribution-    { attVerdict = v-    , attAA      = raa-    , attBB      = rbb-    }+  in  Attribution+        { attVerdict = v+        , attAA      = raa+        , attBB      = rbb+        }  -- internal @@ -955,16 +1013,51 @@   !t1 <- measure meter (cfgBatch cfg) (target hyp u)   !v  <- indexSmallArray samplers iSnd   !t2 <- measure meter (cfgBatch cfg) (target hyp v)-  -- branchless unscramble: swap iff bit = 1, via an all-ones mask.-  -- x is class A's reading (slot 1 when the bit is 0, slot 2 when-  -- it is 1), y class B's.-  let !m = negate bit-      !s = (t1 `B.xor` t2) B..&. m-      !x = t1 `B.xor` s-      !y = t2 `B.xor` s+  let (!x, !y) = unscramble bit t1 t2   pure (x, y) {-# INLINE runPair #-} +-- | 'runPair' under a t'SplitMeter': (A's cycles, A's wall time,+--   B's cycles, B's wall time), each reading unscrambled as there.+runSplitPair+  :: SplitMeter -> Config -> Hypothesis a -> SmallArray (IO a) -> Word64+  -> IO (Word64, Word64, Word64, Word64)+runSplitPair meter cfg hyp samplers !bit = do+  prepare hyp+  let !iFst = fromIntegral bit :: Int+      !iSnd = 1 - iFst+  !u <- indexSmallArray samplers iFst+  (!c1, !w1) <- measureSplit meter (cfgBatch cfg) (target hyp u)+  !v <- indexSmallArray samplers iSnd+  (!c2, !w2) <- measureSplit meter (cfgBatch cfg) (target hyp v)+  let (!ca, !cb) = unscramble bit c1 c2+      (!wa, !wb) = unscramble bit w1 w2+  pure (ca, wa, cb, wb)+{-# INLINE runSplitPair #-}++-- branchless unscramble: swap iff bit = 1, via an all-ones mask. The+-- first result is class A's reading (slot 1 when the bit is 0, slot+-- 2 when it is 1), the second class B's.+unscramble :: Word64 -> Word64 -> Word64 -> (Word64, Word64)+unscramble !bit !t1 !t2 =+  let !m = negate bit+      !s = (t1 `B.xor` t2) B..&. m+  in  (t1 `B.xor` s, t2 `B.xor` s)+{-# INLINE unscramble #-}++-- Each channel's (A, B) readings from a split pair. The period is+-- wall time per cycle in femtoseconds, fine enough that the clip+-- bound's floor of 1 sits far below its noise. A batch always+-- retires cycles; the floor on the divisor only keeps the ratio+-- total.+splitReadings :: (Word64, Word64, Word64, Word64) -> Split (Double, Double)+splitReadings (!ca, !wa, !cb, !wb) = Split+  (fromIntegral ca, fromIntegral cb)+  (period wa ca, period wb cb)+  (fromIntegral wa, fromIntegral wb)+  where+    period !w !c = fromIntegral w * 1.0e6 / fromIntegral (max 1 c)+ -- The hedge composition ----------------------------------------------------  -- The components are heterogeneous, so the driver steps each one@@ -1037,6 +1130,109 @@     , hcMargin = mmargin     } +-- One reading stream under test: its hedge and the confidence+-- sequence behind 'resEffect', both resting on the calibration+-- 'channel' fixes from the warmup pairs. A run tests one channel per+-- quantity it reads.+data Chan = Chan+  { chHedge :: !HedgeCfg+  , chSeq   :: !EPCS.Config+  }++data ChanState = ChanState+  { csHedge :: !HedgeState+  , csSeq   :: !EPCS.State+  , csIv    :: !(Double, Double)  -- the latched effect interval+  , csClips :: !Clips+  }++-- Calibrate a channel from its warmup (A, B) readings, resolving the+-- margin against them. Throws as 'runCTWith' documents.+channel :: Config -> [(Double, Double)] -> IO Chan+channel cfg ws = do+  let (!c, !qs, !meterResolves, !medR) = calibrate ws+  unless meterResolves $ throwIO WarmupZeroDuration+  !mdelta <- case cfgMargin cfg of+    Nothing  -> pure Nothing+    Just rel -> do+      let !delta = rel * medR+      unless (delta > 0) $ throwIO $! InvalidConfig+        "cfgMargin resolves to a zero absolute margin (warmup \+        \median reading is 0); raise cfgBatch"+      unless (delta < c) $ throwIO $! InvalidConfig+        "cfgMargin resolves to an absolute margin at or above the \+        \warmup clip bound; lower cfgMargin (the tolerance exceeds \+        \the pair-noise scale the test bets against)"+      pure (Just delta)+  !hcfg <- case mkHedgeCfg (cfgAlpha cfg) c qs mdelta of+    Left  e -> throwIO (InvalidEprocConfig e)+    Right x -> pure x+  !cscfg <- case EPCS.config (negate (hcC3 hcfg)) (hcC3 hcfg)+                   (cfgAlpha cfg) effectGrid of+    Left  e -> throwIO (InvalidEprocConfig e)+    Right x -> pure x+  pure (Chan hcfg cscfg)++chanInitial :: Chan -> ChanState+chanInitial Chan{..} = ChanState+  { csHedge = initialHedge chHedge+  , csSeq   = EPCS.initial chSeq+  , csIv    = (negate (hcC3 chHedge), hcC3 chHedge)+  , csClips = noClips+  }++-- Step a channel on one pair's (A, B) readings. The effect interval+-- is latched: once the evidence pins the mean below the grid's+-- resolution the survivor set empties, and the last resolvable+-- interval (nested within all earlier ones, so still covered by the+-- time-uniform guarantee) is what 'chanResult' reports.+stepChan :: Chan -> ChanState -> Double -> Double -> ChanState+stepChan Chan{..} ChanState{..} !xa !xb =+  let !d   = xa - xb+      !h'  = updateHedge chHedge csHedge d xa xb+      !s'  = EPCS.update chSeq csSeq (clipD (hcC3 chHedge) d)+      !iv' = case EPCS.interval chSeq s' of+               Just x  -> x+               Nothing -> csIv+      !cl' = bumpClips chHedge csClips d+  in  ChanState h' s' iv' cl'++-- Whether the channel's mixture has crossed its threshold; latched.+chanRejects :: Chan -> ChanState -> Bool+chanRejects Chan{..} ChanState{..} =+  case EPMix.decide (hcMix chHedge) (hsMix csHedge) of+    EPMix.Reject -> True+    _            -> False++-- The channel's frame after pair @n@, whose difference was @d@.+chanFrame :: Chan -> Int -> Double -> ChanState -> Frame+chanFrame Chan{..} !n !d ChanState{..} = Frame n d+  (EPMix.log_evalue mixc (hsMix csHedge))+  (EPMix.log_evalue_sup mixc (hsMix csHedge))+  (fst csIv) (snd csIv)+  (abs d > hcC1 chHedge)+  where+    mixc = hcMix chHedge++-- The channel's t'Result' after @n@ pairs, under the run's root seed.+chanResult :: Chan -> Word64 -> Int -> ChanState -> Result+chanResult ch@Chan{..} !root !n st@ChanState{..} = ctor+  n (EPMix.log_evalue_sup mixc (hsMix csHedge))+  (EPMix.p_value mixc (hsMix csHedge))+  csIv+  (clAtC csClips) (clAt4C csClips) (clAt16C csClips) (hcC1 chHedge)+  (EPSign.log_evalue_sup (hsSign csHedge))+  (EPMagn.log_evalue_sup (hsMagn1 csHedge))+  (EPMagn.log_evalue_sup (hsMagn2 csHedge))+  (EPMagn.log_evalue_sup (hsMagn3 csHedge))+  (cdfPeaks (hcQs chHedge) (hsCdf csHedge))+  root+  (hcMargin chHedge)+  where+    mixc = hcMix chHedge+    ctor | chanRejects ch st = Reject+         | otherwise         = Pass+ -- Running clipped-pair tallies at the hedge's three magnitude -- bounds. Kept as one strict record rather than three accumulator -- arguments so the driver loop's arity does not grow with them.@@ -1191,7 +1387,19 @@       | otherwise         = Nothing     step !acc !w = acc * 256 + fromIntegral w --- Warmup: run @cfgWarmup@ randomly ordered pairs and return+-- Warmup: run @cfgWarmup@ randomly ordered pairs, returning each+-- pair's readings for 'channel' to calibrate from.+warmup :: Config -> (Word64 -> IO r) -> Word64 -> IO [r]+warmup cfg pair = go 0 []+  where+    go !i !acc !rng+      | i >= cfgWarmup cfg = pure acc+      | otherwise = do+          let (!rng', !bit) = nextOrderBit rng+          !r <- pair bit+          go (i + 1) (r : acc) rng'++-- Calibrate from warmup (A, B) readings, returning -- @(c, qs, meterResolves, medReading)@: -- --   * @c = max 1 (2 * p99(|d|))@, the clip bound; the floor keeps it@@ -1206,28 +1414,17 @@ --     throws 'WarmupZeroDuration'. --   * @medReading@, the pooled median, which resolves a relative --     'cfgMargin'.-warmup-  :: Meter -> Config -> Hypothesis a -> SmallArray (IO a) -> Word64-  -> IO (Double, [Double], Bool, Double)-warmup meter cfg hyp samplers seed = do-  let go !i !rng !accD !accR-        | i >= cfgWarmup cfg = pure (accD, accR)-        | otherwise = do-            let (!rng', !bit) = nextOrderBit rng-            (!x, !y) <- runPair meter cfg hyp samplers bit-            let !dx = fromIntegral x :: Double-                !dy = fromIntegral y :: Double-                !da = abs (dx - dy)-            go (i + 1) rng' (da : accD) (x : y : accR)-  (diffs, readings) <- go 0 seed [] []-  let !sortedR = sort readings-      !p99d    = orderStat99 diffs-      !p99r    = orderStat99 readings-      !qs      = cutPoints sortedR-      !medR    = fromIntegral (nth (length sortedR `div` 2) sortedR)+calibrate :: [(Double, Double)] -> (Double, [Double], Bool, Double)+calibrate ws =+  let !readings = concat [ [x, y] | (x, y) <- ws ]+      !sortedR  = sort readings+      !p99d     = orderStat99 [ abs (x - y) | (x, y) <- ws ]+      !p99r     = orderStat99 readings+      !qs       = cutPoints sortedR+      !medR     = nth (length sortedR `div` 2) sortedR   -- twice the order statistic leaves headroom for occasional   -- outliers; larger differences are clipped in the main phase.-  pure (max 1 (2 * p99d), qs, p99r > 0, medR)+  in  (max 1 (2 * p99d), qs, p99r > 0, medR)  -- Empirical @~p99@ order statistic. Sorts and picks index -- @(m * 99) \/ 100@; degenerates to the sample max at small @m@@@ -1251,15 +1448,13 @@ -- Nearest-rank order statistics of a sorted reading list at -- 'cdfLevels', ascending and deduplicated. Empty when warmup -- collected nothing.-cutPoints :: [Word64] -> [Double]+cutPoints :: [Double] -> [Double] cutPoints sorted   | m == 0    = []   | otherwise = dedupAsc (map pick cdfLevels)   where     !m = length sorted-    pick !p =-      fromIntegral (nth (max 0 (min (m - 1) (floor (p * fromIntegral m))))-                        sorted)+    pick !p = nth (max 0 (min (m - 1) (floor (p * fromIntegral m)))) sorted  -- Drop repeats from an ascending list. dedupAsc :: [Double] -> [Double]
lib/Censor/Meter.hs view
@@ -17,7 +17,9 @@ -- properties of the executed code, immune to the frequency, cache, -- and scheduling noise that limits the wall clock on fast targets, -- while the other counters help attribute a difference to a channel--- (see t'Counter').+-- (see t'Counter'). 'withSplitMeter' reads the cycle counter and the+-- wall clock over the same execution, splitting wall time into the+-- cycles the code took and the clock period they ran at.  module Censor.Meter (     -- * Meter type@@ -30,6 +32,10 @@   , Counter(..)   , MeterError(..)   , withCounter++    -- * Wall time split into cycles and period (Linux)+  , SplitMeter(..)+  , withSplitMeter   ) where  import Control.Exception (Exception, throwIO)@@ -52,6 +58,17 @@   { measure :: Int -> IO () -> IO Word64   } +-- | A meter reading two quantities over one execution of the batch:+--   @'measureSplit' m k act@ returns @(cycles, nanoseconds)@. Wall+--   time is cycles times the clock period, and a secret can move it+--   through either factor: through cycles when the code's execution+--   depends on it, through the period when the core's frequency+--   does (data-dependent power under frequency scaling). Reading+--   both on the same execution tells the two apart.+newtype SplitMeter = SplitMeter+  { measureSplit :: Int -> IO () -> IO (Word64, Word64)+  }+ -- | Wall-clock meter: elapsed nanoseconds over the batch. -- --   On Darwin, backed by @clock_gettime_nsec_np(CLOCK_UPTIME_RAW)@,@@ -164,11 +181,26 @@ --   to a user-only clock. A counter is bound to the thread that --   opened it, so the callback runs on one OS thread. withCounter :: Counter -> (Meter -> IO a) -> IO a-withCounter counter k = onOneThread $ do+withCounter counter k = withPerf counter (k . meterFor)++-- | Acquire a t'SplitMeter' for the duration of the callback, then+--   release it. Throws 'MeterError' if the cycle counter cannot be+--   opened.+--+--   The wall clock is read inside the counted region, so both+--   readings cover the batch and the clock reads; the overhead is+--   the same in both classes. As with 'withCounter', the callback+--   runs on one OS thread.+withSplitMeter :: (SplitMeter -> IO a) -> IO a+withSplitMeter k = withPerf Cycles (k . splitFor)++-- open a counter on one OS thread for the callback, closing it after.+withPerf :: Counter -> (CInt -> IO a) -> IO a+withPerf counter k = onOneThread $ do   fd <- c_perf_open (counterCode counter)   if fd < 0     then throwIO (OpenFailed (negate (fromIntegral fd)))-    else k (meterFor fd) `finally` c_perf_close fd+    else k fd `finally` c_perf_close fd   where     -- the perf fd is thread-affine; keep open + measures on one OS     -- thread. runInBoundThread requires the threaded RTS, so fall back@@ -184,11 +216,26 @@   c_perf_end fd {-# INLINE meterFor #-} +splitFor :: CInt -> SplitMeter+splitFor !fd = SplitMeter $ \k act -> do+  c_perf_begin fd+  !t0 <- monotonicNS+  rep k act+  !t1 <- monotonicNS+  !cy <- c_perf_end fd+  pure (cy, t1 - t0)+{-# INLINE splitFor #-}+ #else  -- | Hardware performance-counter meters are only available on Linux; --   on other platforms this always throws 'Unsupported'. withCounter :: Counter -> (Meter -> IO a) -> IO a withCounter _ _ = throwIO Unsupported++-- | The split meter needs the cycle counter, so it is only available+--   on Linux; on other platforms this always throws 'Unsupported'.+withSplitMeter :: (SplitMeter -> IO a) -> IO a+withSplitMeter _ = throwIO Unsupported  #endif
lib/Censor/Runner.hs view
@@ -18,6 +18,8 @@   , parseMeter   , meterName   , withMeterArg+  , splitMeterName+  , withSplitMeterArg      -- * Recording traces   , Trace(..)@@ -92,17 +94,33 @@       case r of         Right a -> pure a         Left e  -> die $ name ++ " meter unavailable: "-          ++ show (e :: MeterError) ++ hint e-  where-    -- EPERM/EACCES means the kernel refused a counter that exists,-    -- which for an unprivileged process is the paranoid level.-    -- task-clock counts kernel time and so needs a lower one than-    -- the rest; see Censor.Meter.-    hint (OpenFailed n)-      | n == 1 || n == 13 =-          " (check kernel.perf_event_paranoid: task-clock needs 1"-            ++ " or lower, the other counters 2 or lower)"-    hint _ = ""+          ++ show (e :: MeterError) ++ paranoidHint e++-- | The split meter's name on the command line and in reports+--   (@cycles+wall@; see 'withSplitMeter').+splitMeterName :: String+splitMeterName = "cycles+wall"++-- | Run the callback with a t'SplitMeter', dying with a clear message+--   if the cycle counter cannot be opened, as 'withMeterArg' does.+withSplitMeterArg :: (SplitMeter -> IO a) -> IO a+withSplitMeterArg k = do+  r <- try (withSplitMeter k)+  case r of+    Right a -> pure a+    Left e  -> die $ splitMeterName ++ " meter unavailable: "+      ++ show (e :: MeterError) ++ paranoidHint e++-- EPERM/EACCES means the kernel refused a counter that exists, which+-- for an unprivileged process is the paranoid level. task-clock+-- counts kernel time and so needs a lower one than the rest; see+-- Censor.Meter.+paranoidHint :: MeterError -> String+paranoidHint (OpenFailed n)+  | n == 1 || n == 13 =+      " (check kernel.perf_event_paranoid: task-clock needs 1"+        ++ " or lower, the other counters 2 or lower)"+paranoidHint _ = ""  -- recording ------------------------------------------------------------------- 
lib/Censor/Runner/Report.hs view
@@ -34,6 +34,7 @@     -- * Data model   , ReportHeader(..)   , CaseReport(..)+  , ChannelReport(..)   , BatteryTail(..)      -- * Renderers@@ -88,6 +89,7 @@ import Censor.Runner   ( traceResult, traceFrames, record, thin   , verdictName, shapeName, clipText, withMeterArg+  , splitMeterName, withSplitMeterArg   ) import Censor.Runner.Env (Env(..), captureEnv) import qualified Paths_ppad_censor as Paths@@ -181,8 +183,25 @@     -- ^ per-pair 'Frame' trajectory, when the runner recorded one     --   (via 'record'). Surfaces in the JSON artefact as a thinned     --   array; the human renderers ignore it.+  , crChannels    :: ![ChannelReport]+    -- ^ further channels read on the case's own pairs and reported+    --   beside its verdict: the period and wall channels of a split+    --   run ('Censor.runSplitWith'), whose 'crResult' is the cycles+    --   channel. Empty under a single meter.   } deriving Show +-- | A channel reported beside a case's verdict.+data ChannelReport = ChannelReport+  { chName        :: !String+    -- ^ channel name (@period@, @wall@).+  , chUnit        :: !String+    -- ^ the unit of its readings (@fs@ per cycle, @ns@).+  , chResult      :: !Result+    -- ^ the channel's own test over the case's pairs.+  , chAttribution :: !(Maybe Attribution)+    -- ^ the channel's A\/A + B\/B controls, on a rejection.+  } deriving Show+ -- | The tail passed to a renderer's 'rndClose'. Carries the --   accumulated case list and, optionally, the path to a companion --   artefact (a trace JSON, a log) that the tool wrote alongside.@@ -423,10 +442,28 @@       Pass{} -> []   , maybe [] pure (calibText cr)   , maybe [] (pure . attributionText) (crAttribution cr)+  , concatMap channelLines (crChannels cr)   ]   where     r = crResult cr +-- a reported-beside channel's lines: its verdict, evidence, and+-- effect interval in its own unit, the driver on a rejection, and+-- its controls when they ran.+channelLines :: ChannelReport -> [String]+channelLines ch =+  printf "%s %s peakLogW=%.2f p=%.2g effect=[%.1f,%.1f] %s%s"+    (chName ch) (verdictName r) (resPeakLogW r) (resPValue r)+    elo ehi (chUnit ch) drv+  : maybe [] (pure . ((chName ch ++ " ") ++) . attributionText)+      (chAttribution ch)+  where+    r = chResult ch+    (elo, ehi) = resEffect r+    drv = case r of+      Reject{} -> " driver=" ++ shapeName (leakShape r)+      Pass{}   -> ""+ -- the run-metadata line under a case row: resolved batch, the -- meter's noise floor at that batch when recorded, the target's -- baseline cost when probed, and the order seed the run resolved@@ -511,6 +548,11 @@ --   @orderSeed@ inside each case result is the resolved per-run --   A\/B order seed that replays that case. --+--   A split run's case carries a @channels@ array: the period and+--   wall channels read on its own pairs, each with its name, unit,+--   result, and controls on a rejection. The case's @result@ is the+--   cycles channel, which carries the verdict.+-- --   The 'json' renderer and 'runTraceSuite' both call this so --   stdout output and the on-disk artefact are byte-identical. reportJSON :: ReportHeader -> [CaseReport] -> String@@ -585,10 +627,24 @@   , ",\"result\":", resultToJSON (crResult cr)   , maybe "" (\a -> ",\"controls\":" ++ controlsJSON a)       (crAttribution cr)+  , case crChannels cr of+      []  -> ""+      chs -> ",\"channels\":["+        ++ intercalate "," (map channelJSON chs) ++ "]"   , maybe "" (\frs -> ",\"trace\":" ++ traceArrayJSON frs) (crTrace cr)   , "}"   ] +channelJSON :: ChannelReport -> String+channelJSON ch = concat+  [ "{\"name\":",   jstr (chName ch)+  , ",\"unit\":",   jstr (chUnit ch)+  , ",\"result\":", resultToJSON (chResult ch)+  , maybe "" (\a -> ",\"controls\":" ++ controlsJSON a)+      (chAttribution ch)+  , "}"+  ]+ -- | Serialise a t'Result' as a stable JSON object. Includes verdict, --   pairs, mixture peak log e-value, anytime-valid p-value, effect --   interval, clip counts at each of the three magnitude bounds,@@ -931,6 +987,13 @@ --   even for pure targets), 'record' streams the run and keeps its --   frames, and on 'Reject' 'attribute' runs the A\/A + B\/B --   diagnostics automatically (two extra runs).+--+--   The meter @cycles+wall@ ('splitMeterName') runs each case under+--   the split meter ('Censor.runSplitWith'): the case's result is+--   the cycles channel, and the period and wall channels of the same+--   pairs are reported beside it ('crChannels'). Calibration and the+--   baseline read cycles, and the controls run when any channel+--   rejects. runTraceSuite :: TraceSuite -> IO () runTraceSuite ts = do   hSetBuffering stdout LineBuffering@@ -943,62 +1006,121 @@   let out = case saExtra sa of         (o:_) -> Just o         _     -> Nothing-  withMeterArg (saMeter sa) $ \mname meter -> do-    env <- captureEnv-    let cfgBase = (tsConfig ts)-          { cfgMargin =-              maybe (cfgMargin (tsConfig ts)) Just (saMargin sa) }-        hdr = ReportHeader-          { rhMeter     = mname-          , rhConfig    = cfgBase-              { cfgBatch = fromMaybe (cfgBatch cfgBase) (saBatch sa) }-          , rhSeed      = tsSeed ts-          , rhTarget    = Just (tsTarget ts)-          , rhNote      = tsNote ts-          , rhEnv       = Just env-          , rhPerCaseBatch = maybe True (const False) (saBatch sa)-          }-        path = fromMaybe (prog ++ "-" ++ mname ++ ".json") out-    ref <- newIORef ([] :: [CaseReport])-    _ <- withReport rc (saRenderer sa) hdr $ \emit -> do-      forM_ (tsCases ts) $ \(TraceCase name policy mkH) -> do-        -- --batch overrides every case's policy; otherwise the-        -- case's own declared policy governs, and only-        -- RepeatableAuto runs a calibration probe.-        ---        -- Each probe gets its own instance: it runs a prologue and-        -- class-A draws, and the main run should start from the-        -- same state whether or not any probe happened.-        (b, mnoise) <- case maybe policy FixedBatch (saBatch sa) of-          SingleShot     -> pure (1, Nothing)-          FixedBatch n   -> pure (n, Nothing)-          RepeatableAuto -> do-            hCal <- mkH-            cb   <- calibrateBatchReport meter hCal-            pure (C.crBatch cb, C.crNoise cb)-        let c = cfgBase { cfgBatch = b }-        hBase <- mkH-        base  <- baselineReading meter c hBase-        h <- mkH-        t <- record name mname meter c h-        let r = traceResult t-        mat <- case r of-          Reject{} -> do-            hAtt <- mkH-            Just <$> attribute meter c hAtt-          Pass{}   -> pure Nothing-        let cr = CaseReport-              { crName        = name-              , crResult      = r-              , crAttribution = mat-              , crBatch       = Just b-              , crNoise       = mnoise-              , crBaseline    = Just base-              , crTrace       = Just (traceFrames t)+      suite mname runCase = do+        env <- captureEnv+        let cfgBase = (tsConfig ts)+              { cfgMargin =+                  maybe (cfgMargin (tsConfig ts)) Just (saMargin sa) }+            hdr = ReportHeader+              { rhMeter     = mname+              , rhConfig    = cfgBase+                  { cfgBatch = fromMaybe (cfgBatch cfgBase) (saBatch sa) }+              , rhSeed      = tsSeed ts+              , rhTarget    = Just (tsTarget ts)+              , rhNote      = tsNote ts+              , rhEnv       = Just env+              , rhPerCaseBatch = maybe True (const False) (saBatch sa)               }-        modifyIORef' ref (cr :)-        emit cr-      cases <- fmap reverse (readIORef ref)-      writeFile path (reportJSON hdr cases)-      pure (Just path)-    pure ()+            path = fromMaybe (prog ++ "-" ++ mname ++ ".json") out+        ref <- newIORef ([] :: [CaseReport])+        _ <- withReport rc (saRenderer sa) hdr $ \emit -> do+          forM_ (tsCases ts) $ \tc -> do+            cr <- runCase cfgBase (saBatch sa) tc+            modifyIORef' ref (cr :)+            emit cr+          cases <- fmap reverse (readIORef ref)+          writeFile path (reportJSON hdr cases)+          pure (Just path)+        pure ()+  case saMeter sa of+    Just m | m == splitMeterName ->+      withSplitMeterArg (suite splitMeterName . splitCase)+    marg -> withMeterArg marg $ \mname meter ->+      suite mname (meterCase mname meter)++-- One case under a single meter: the batch, the baseline, the+-- recorded run, and the controls on a rejection.+meterCase :: String -> Meter -> Config -> Maybe Int -> TraceCase+          -> IO CaseReport+meterCase mname meter cfgBase mbatch (TraceCase name policy mkH) = do+  (b, mnoise) <- caseBatch meter mbatch policy mkH+  let c = cfgBase { cfgBatch = b }+  hBase <- mkH+  base  <- baselineReading meter c hBase+  h <- mkH+  t <- record name mname meter c h+  let r = traceResult t+  mat <- case r of+    Reject{} -> do+      hAtt <- mkH+      Just <$> attribute meter c hAtt+    Pass{}   -> pure Nothing+  pure CaseReport+    { crName        = name+    , crResult      = r+    , crAttribution = mat+    , crBatch       = Just b+    , crNoise       = mnoise+    , crBaseline    = Just base+    , crTrace       = Just (traceFrames t)+    , crChannels    = []+    }++-- One case under the split meter: as 'meterCase', with the cycles+-- channel as the case's result (batch, noise, and baseline are in+-- cycles too) and the period and wall channels beside it. The+-- controls run when any channel rejects, and each rejecting channel+-- carries its own.+splitCase :: SplitMeter -> Config -> Maybe Int -> TraceCase+          -> IO CaseReport+splitCase meter cfgBase mbatch (TraceCase name policy mkH) = do+  let cyc = Meter (\k act -> fmap fst (measureSplit meter k act))+  (b, mnoise) <- caseBatch cyc mbatch policy mkH+  let c = cfgBase { cfgBatch = b }+  hBase <- mkH+  base  <- baselineReading cyc c hBase+  h   <- mkH+  ref <- newIORef []+  sp  <- runSplitWith meter c h $ \ !fr -> modifyIORef' ref (fr :)+  frs <- fmap reverse (readIORef ref)+  matt <- if any isReject [splitCycles sp, splitPeriod sp, splitWall sp]+    then do+      hAtt <- mkH+      Just <$> attributeSplit meter c hAtt+    else pure Nothing+  let ctl r a+        | isReject r = a+        | otherwise  = Nothing+      chan nm unit r a = ChannelReport nm unit r (ctl r a)+  pure CaseReport+    { crName        = name+    , crResult      = splitCycles sp+    , crAttribution = ctl (splitCycles sp) (fmap splitCycles matt)+    , crBatch       = Just b+    , crNoise       = mnoise+    , crBaseline    = Just base+    , crTrace       = Just frs+    , crChannels    =+        [ chan "period" "fs" (splitPeriod sp) (fmap splitPeriod matt)+        , chan "wall" "ns" (splitWall sp) (fmap splitWall matt)+        ]+    }++-- A case's batch, and the meter's noise floor at it when calibrated.+-- --batch overrides every case's policy; otherwise the case's own+-- declared policy governs, and only RepeatableAuto runs a+-- calibration probe.+--+-- Each probe gets its own instance: it runs a prologue and class-A+-- draws, and the main run should start from the same state whether+-- or not any probe happened.+caseBatch+  :: Meter -> Maybe Int -> BatchPolicy -> IO (Hypothesis a)+  -> IO (Int, Maybe Noise)+caseBatch meter mbatch policy mkH = case maybe policy FixedBatch mbatch of+  SingleShot     -> pure (1, Nothing)+  FixedBatch n   -> pure (n, Nothing)+  RepeatableAuto -> do+    hCal <- mkH+    cb   <- calibrateBatchReport meter hCal+    pure (C.crBatch cb, C.crNoise cb)
ppad-censor.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               ppad-censor-version:            0.5.2+version:            0.6.0 synopsis:           Anytime-valid sequential constant-time testing. license:            MIT license-file:       LICENSE
run/Main.hs view
@@ -454,6 +454,7 @@     , Rep.crNoise       = Nothing     , Rep.crBaseline    = Just base     , Rep.crTrace       = mfrs+    , Rep.crChannels    = []     }   where     withHyp k = mk >>= \fh -> F.withFFIHypothesis fh k
test/Main.hs view
@@ -29,6 +29,7 @@   , configValidationTests   , deterministicLeakTests   , runCTWithTests+  , splitTests   , evidenceTests   , offByOneTests   , clipCountTests@@ -311,6 +312,135 @@           "expected InvalidConfig, got " ++ show other         Right _ -> assertFailure "expected InvalidConfig" +-- A split meter over synthetic readings: the target writes its+-- class's (base cycles, picoseconds per cycle) into the ref, and each+-- batch reads the base plus class-blind noise from a fixed-seed+-- stream, with wall time the noisy cycles at that period plus a+-- small noisy overhead.+synthSplit :: IO (C.SplitMeter, IORef (Word64, Word64))+synthSplit = do+  g   <- C.mkRng 0x5EED+  ref <- newIORef (0, 0)+  let m = C.SplitMeter $ \k act -> do+        let go 0 = pure ()+            go i = act >> go (i - 1 :: Int)+        go k+        (cy0, ps) <- readIORef ref+        w <- C.nextWord g+        let !cy = cy0 + w `mod` 64+            !ns = cy * ps `div` 1000 + (w `div` 64) `mod` 4+        pure (cy, ns)+  pure (m, ref)++splitHyp+  :: IORef (Word64, Word64) -> (Word64, Word64) -> (Word64, Word64)+  -> C.Hypothesis (Word64, Word64)+splitHyp ref a b = C.Hypothesis+  { C.target  = writeIORef ref+  , C.prepare = pure ()+  , C.sampleA = pure a+  , C.sampleB = pure b+  }++splitCfg :: C.Config+splitCfg = C.defaultConfig+  { C.cfgAlpha  = 1.0e-4+  , C.cfgBudget = 3000+  , C.cfgWarmup = 150+  , C.cfgSeed   = Just 0xABCDEF+  }++isPass :: C.Result -> Bool+isPass C.Pass{}   = True+isPass C.Reject{} = False++splitTests :: TestTree+splitTests = testGroup "split meter" [+    testCase "a clock that follows the class: period and wall reject, \+             \cycles pass" $ do+      (m, ref) <- synthSplit+      sp <- C.runSplit m splitCfg+              (splitHyp ref (1000, 1020) (1000, 1000))+      assertBool ("cycles: " ++ show (C.splitCycles sp))+        (isPass (C.splitCycles sp))+      assertBool ("period: " ++ show (C.splitPeriod sp))+        (not (isPass (C.splitPeriod sp)))+      assertBool ("wall: " ++ show (C.splitWall sp))+        (not (isPass (C.splitWall sp)))+      C.resPairs (C.splitCycles sp) @?= C.cfgBudget splitCfg++  , testCase "a cycles leak stops the run for every channel" $ do+      (m, ref) <- synthSplit+      sp <- C.runSplit m splitCfg+              (splitHyp ref (1100, 1000) (1000, 1000))+      assertBool ("cycles: " ++ show (C.splitCycles sp))+        (not (isPass (C.splitCycles sp)))+      let ns = fmap C.resPairs sp+      assertBool ("stopped early: " ++ show ns)+        (C.splitCycles ns < C.cfgBudget splitCfg)+      C.splitPeriod ns @?= C.splitCycles ns+      C.splitWall ns @?= C.splitCycles ns++  , testCase "the period channel reads femtoseconds per cycle" $ do+      (m, ref) <- synthSplit+      sp <- C.runSplit m splitCfg+              (splitHyp ref (1000, 1020) (1000, 1000))+      -- class A's clock is 20 ps per cycle slower: 2e4 fs+      let (lo, hi) = C.resEffect (C.splitPeriod sp)+      assertBool ("period effect " ++ show (lo, hi))+        (lo < 2.0e4 && 2.0e4 < hi + 2.0e3 && lo > 1.0e4)++  , testCase "frames are the cycles channel's" $ do+      (m, ref) <- synthSplit+      fr <- newIORef (0 :: Int)+      sp <- C.runSplitWith m splitCfg+              (splitHyp ref (1100, 1000) (1000, 1000))+              (\_ -> modifyIORef' fr (+ 1))+      n <- readIORef fr+      n @?= C.resPairs (C.splitCycles sp)++  , testCase "controls carry a verdict per channel" $ do+      (m, ref) <- synthSplit+      att <- C.attributeSplit m splitCfg+               (splitHyp ref (1000, 1020) (1000, 1000))+      fmap C.attVerdict att @?=+        C.Split C.ControlsPass C.ControlsPass C.ControlsPass++  , testCase "unavailable off Linux" $+      if Info.os == "linux"+        then pure ()+        else do+          r <- try (C.withSplitMeter (\_ -> pure ()))+          case r of+            Left C.Unsupported -> pure ()+            other -> assertFailure ("expected Unsupported, got "+              ++ show other)++  , testCase "report JSON carries the channels beside the result" $ do+      (m, ref) <- synthSplit+      sp <- C.runSplit m splitCfg+              (splitHyp ref (1000, 1020) (1000, 1000))+      let hdr = Rep.ReportHeader+            { Rep.rhMeter     = R.splitMeterName+            , Rep.rhConfig    = splitCfg+            , Rep.rhSeed      = Nothing+            , Rep.rhTarget    = Nothing+            , Rep.rhNote      = Nothing+            , Rep.rhEnv       = Nothing+            , Rep.rhPerCaseBatch = False+            }+          cr = Rep.CaseReport "case" (C.splitCycles sp) Nothing+                 Nothing Nothing Nothing Nothing+                 [ Rep.ChannelReport "period" "fs" (C.splitPeriod sp)+                     Nothing ]+          j  = Rep.reportJSON hdr [cr]+      assertBool ("meter in " ++ j)+        ("\"meter\":\"cycles+wall\"" `isInfixOf` j)+      assertBool ("channels in " ++ j)+        ("\"channels\":[{\"name\":\"period\",\"unit\":\"fs\",\+         \\"result\":{\"verdict\":\"REJECT\"" `isInfixOf` j)+  ]+ -- deterministic A/B divergence via a meter that reports state the -- target wrote during the timed region. No wall-clock noise: every -- pair lands the same delta into the wealth process and rejection is@@ -1302,6 +1432,7 @@             , Rep.crNoise       = Just (C.Noise 100 5)             , Rep.crBaseline    = Just (C.Noise 41000 12)             , Rep.crTrace       = Nothing+            , Rep.crChannels    = []             }           j = Rep.reportJSON hdr [cr]       assertBool ("expected env block in " ++ j)@@ -1325,10 +1456,12 @@             , Rep.rhPerCaseBatch = False             }           cr = Rep.CaseReport "case" r Nothing Nothing Nothing Nothing-                 Nothing+                 Nothing []           j  = Rep.reportJSON hdr [cr]       assertBool ("unexpected env block in " ++ j)         (not ("\"env\":" `isInfixOf` j))+      assertBool ("unexpected channels in " ++ j)+        (not ("\"channels\":" `isInfixOf` j))       assertBool ("unexpected noise in " ++ j)         (not ("\"noise\":" `isInfixOf` j))       assertBool ("unexpected baseline in " ++ j)