Skip to content

window-stats: efficient rolling window statistics - #5384

Open
crocodile-dentist wants to merge 8 commits into
mainfrom
mw/window-stats
Open

window-stats: efficient rolling window statistics#5384
crocodile-dentist wants to merge 8 commits into
mainfrom
mw/window-stats

Conversation

@crocodile-dentist

@crocodile-dentist crocodile-dentist commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Description

Efficient sliding-window statistics backed by a finger tree, with a cached user-supplied measure that updates incrementally as the window slides.

This library is a first step towards exposing connection metrics to our applications.

Checklist

Quality

  • Commit sequence makes sense and have useful messages, see ref.
  • New tests are added and existing tests are updated.
  • Self-reviewed the PR.

Maintenance

  • Linked an issue or added the PR to the current sprint of ouroboros-network project.
  • Added labels.
  • Updated changelog files.
  • The documentation has been properly updated, see ref.

@crocodile-dentist crocodile-dentist self-assigned this Jun 12, 2026
@crocodile-dentist
crocodile-dentist requested a review from a team as a code owner June 12, 2026 12:17
@github-project-automation github-project-automation Bot moved this to In Progress in Ouroboros Network Jun 12, 2026
@crocodile-dentist
crocodile-dentist marked this pull request as draft June 12, 2026 12:17
@crocodile-dentist
crocodile-dentist force-pushed the mw/window-stats branch 5 times, most recently from b83890a to 2a8b584 Compare June 17, 2026 20:36
@crocodile-dentist
crocodile-dentist marked this pull request as ready for review June 17, 2026 20:37
These modules provide a fingertree-backed sliding window with fixed
element count. The fingertree backend caches a user-supplied monoidal measure
`v` which updates incrementally as elements are added and evicted, which
allows for constant time lookup of its value regardless of window
size.

The Window.Count module exposes a public API, and the private internal
implementation is exposed by the Internal.Count module without any
guarantees that it stays compatible between any releases.

The Measures module exposes some common and practical prebuilt
measures.
@crocodile-dentist
crocodile-dentist force-pushed the mw/window-stats branch 4 times, most recently from ab338cb to 21db2bc Compare July 7, 2026 12:38
These modules keep samples within a configured duration of the newest
sample's timestamp. These variants abstract over the timestamp type
via the 'TimeLike' class, which have two implementations defined:

- `UTCTime` / `NominalDiffTime` (wall-clock, from `time`)
- `Time` / `DiffTime` (monotonic, from `io-classes:si-timers`)

Monotonic is preferred when sliding-window correctness must not be
perturbed by NTP corrections or wall-clock jumps; wall-clock fits
data that already carries `UTCTime` timestamps.
These modules expose sliding windows backed by an approximate t-digest
measure, which permit efficient computations of quantiles in bounded memory.
@crocodile-dentist
crocodile-dentist force-pushed the mw/window-stats branch 2 times, most recently from 8f8d92d to 25ee034 Compare July 8, 2026 11:39
Comment on lines +37 to +38
, tmV :: v
-- ^ user supplied measure

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider making it strict, as all the other fields. A similar tsValue of TimedSample is strict.

Comment on lines +61 to +64
-- | Configured maximum duration of the window.
windowMaxDuration :: TimedWindow t v a -> Dur t
windowMaxDuration = twDuration
{-# INLINE windowMaxDuration #-}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this below TimedWindow.

Btw, the INLINE pragma is not needed, as GHC will do that automatically for small functions.

data WelfordMeasure a = WelfordMeasure
{ welfordN :: {-# UNPACK #-} !Int
, welfordMean :: !a
, welfordM2 :: !a

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

welfordM2 is variance, isn't it? Could you add a haddock.

Comment on lines +109 to +112
m2 = m2A + m2B + delta * delta
* fromIntegral nA
* fromIntegral nB
/ fromIntegral n

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The indentation is misleading, * should be aligned with the second +.

--
class (Ord t, Ord (Dur t), Num (Dur t)) => TimeLike t where
-- | The duration type associated with @t@.
type Dur t = d | d -> t

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duration?

-- for correct behaviour (__note__: this is opposite from 'insertMany' and 'fromFoldable')
-- \(O(\w log w)\)
--
fromListN :: (TimeLike t, FT.Measured v b, Coercible a b)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this if we have fromFoldable?

The implementation could just be fromListN = fromFoldable and we can let GHC to specialise it for a list.

--
data WindowMeasure v = WindowMeasure
{ wmCount :: {-# UNPACK #-} !Int
, wmV :: v

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not making it strict in wmV?

takeUntil p win@Window { windowTree } = win { windowTree = windowTree' }
where
windowTree' = FT.takeUntil p windowTree
!_ = wmV $ FT.measure windowTree'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if WindowMeasure would be strict in wmV, then we won't need the strict pattern match here.


-- | Constructs a window with capacity of @windowMaxSize@ containing a single sample
--
singleton :: (FT.Measured v b, Coercible a b) => Int -> a -> Window v b

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need the Coercible in the API, can't the user coerce the data by him self between the types (which is less surprising)?

FT.EmptyR -> error "impossible"
prefix FT.:> _ -> prefix
else ftree
!_ = FT.measure ftree'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not needed, FingerTree is strict in the cached measure.

@coot coot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A next batch of modules reviewed, this time the DigestTimeBatched.

deriving (Generic, NFData, Show)

instance Foldable (Window v) where
foldMap f Window { windowTree } = foldMap (f . sampleValue) windowTree

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add null, it will be more efficient than the default one.

Comment on lines +90 to +92
instance Foldable (TimedWindow t v) where
foldMap f TimedWindow { twTree } =
foldMap (f . tsValue) twTree

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add null implementation via FT.null . twTree

, obDigest = TD.insert a obDigest
, obCount = succ obCount
})
else (tdwBucket, newBucket)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be easier to read if we'd return the sealed backed - then a type signature fillBucket :: (Maybe SealedBucket, OpenBucket) would explain what's going on.

Nothing -> Nothing
Just bucket -> if obFinish bucket > cutoff
then tdwBucket win
else assert (FT.null tdwTree') Nothing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 we need to make sure evictBefore is never called when the data from open bucket is not moved to the fingertree while it has entries older than cutoff. For insert to force this invariant, the tdwBucketDuration must be smaller than tdwDuration. This is true unless r passed to empty is positive.

For this to be true it is essential that we have dt < tdwBucketDuration on line 160 above in fillBucket, rather than dt <= tdwBucketDuration.

Comment on lines +92 to +99
empty tdwBucketDuration r =
TimedDigestWindow
{ tdwDuration = fromIntegral r * tdwBucketDuration
, tdwBucketDuration
, tdwBucket = Nothing
, tdwTree = FT.empty
, tdwCacheDigest = Nothing
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
empty tdwBucketDuration r =
TimedDigestWindow
{ tdwDuration = fromIntegral r * tdwBucketDuration
, tdwBucketDuration
, tdwBucket = Nothing
, tdwTree = FT.empty
, tdwCacheDigest = Nothing
}
empty _ r | r <= 0 = error "empty: invariant violation, multiplier must be positive"
empty tdwBucketDuration r =
TimedDigestWindow
{ tdwDuration = fromIntegral r * tdwBucketDuration
, tdwBucketDuration
, tdwBucket = Nothing
, tdwTree = FT.empty
, tdwCacheDigest = Nothing
}

See a comment below.

Comment on lines +207 to +210
do
start <- start'
finish <- obFinish <$> tdwBucket
pure $! finish `diffT` start

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use an applicative:

Suggested change
do
start <- start'
finish <- obFinish <$> tdwBucket
pure $! finish `diffT` start
diffT <$> (obFinish <$> tdwBucket)
<*> start'

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

2 participants