packages feed

dunning-t-digest (empty) → 0.1.0.0

raw patch · 8 files changed

+2483/−0 lines, 8 filesdep +basedep +dunning-t-digestdep +fingertreesetup-changed

Dependencies added: base, dunning-t-digest, fingertree, vector

Files

+ CHANGELOG.md view
@@ -0,0 +1,10 @@+# Revision history for dunning-t-digest++## 0.1.0.0 -- 2025-06-01++* Initial release.+* Pure functional t-digest using finger trees (`Data.TDigest.Dunning`).+* Mutable t-digest using mutable vectors in ST (`Data.TDigest.Dunning.Mutable`).+* K1 (arcsine) scale function with O(log n) insertion and queries.+* O(δ log n) split-based compression.+* Freeze/thaw interop between pure and mutable variants.
+ LICENSE view
@@ -0,0 +1,27 @@+Copyright (c) 2025, Nadia Yvette Chambers++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice,+   this list of conditions and the following disclaimer.++2. Redistributions in binary form must reproduce the above copyright notice,+   this list of conditions and the following disclaimer in the documentation+   and/or other materials provided with the distribution.++3. Neither the name of the copyright holder nor the names of its+   contributors may be used to endorse or promote products derived from this+   software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE+POSSIBILITY OF SUCH DAMAGE.
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ app/Main.hs view
@@ -0,0 +1,112 @@+module Main (main) where++import Data.List (foldl')+import Data.Sketch.TDigest++-- ---------------------------------------------------------------------------+-- Formatting helpers+-- ---------------------------------------------------------------------------++showFFloat6 :: Double -> String+showFFloat6 = showFFloatN 6++showFFloat3 :: Double -> String+showFFloat3 = showFFloatN 3++showFFloatN :: Int -> Double -> String+showFFloatN n x+  | isInfinite x = if x > 0 then "Inf" else "-Inf"+  | isNaN x = "NaN"+  | x < 0 = "-" ++ showFFloatN n (negate x)+  | otherwise =+      let factor = 10 ^ n :: Integer+          scaled = round (x * fromIntegral factor) :: Integer+          wholePart = scaled `div` factor+          fracPart = scaled `mod` factor+          fracStr = padLeftZ n (show fracPart)+       in show wholePart ++ "." ++ fracStr++padLeftZ :: Int -> String -> String+padLeftZ n s+  | length s >= n = s+  | otherwise = replicate (n - length s) '0' ++ s++padRight :: Int -> String -> String+padRight n s+  | length s >= n = s+  | otherwise = s ++ replicate (n - length s) ' '++-- ---------------------------------------------------------------------------+-- Demo / self-test+-- ---------------------------------------------------------------------------++main :: IO ()+main = do+  let numValues = 10000 :: Int+      values = [fromIntegral i / fromIntegral numValues | i <- [0 .. numValues - 1]]+      td = foldl' (flip add) empty values++  putStrLn $ "T-Digest demo: " ++ show numValues ++ " uniform values in [0, 1)"+  putStrLn $ "Centroids: " ++ show (centroidCount td)+  putStrLn ""++  putStrLn "Quantile estimates (expected ~ q for uniform):"+  let qs = [0.001, 0.01, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99, 0.999] :: [Double]+  mapM_+    ( \q -> do+        let Just est = quantile q td+            err = abs (est - q)+        putStrLn $+          "  q="+            ++ padRight 6 (showFFloat3 q)+            ++ "  estimated="+            ++ showFFloat6 est+            ++ "  error="+            ++ showFFloat6 err+    )+    qs++  putStrLn ""++  putStrLn "CDF estimates (expected ~ x for uniform):"+  let xs = [0.001, 0.01, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99, 0.999] :: [Double]+  mapM_+    ( \x -> do+        let Just est = cdf x td+            err = abs (est - x)+        putStrLn $+          "  x="+            ++ padRight 6 (showFFloat3 x)+            ++ "  estimated="+            ++ showFFloat6 est+            ++ "  error="+            ++ showFFloat6 err+    )+    xs++  putStrLn ""++  let vals1 = [fromIntegral i / fromIntegral numValues | i <- [0 .. 4999 :: Int]]+      vals2 = [fromIntegral i / fromIntegral numValues | i <- [5000 .. 9999 :: Int]]+      td1 = foldl' (flip add) empty vals1+      td2 = foldl' (flip add) empty vals2+      tdM = merge td1 td2++  putStrLn "After merge of two 5000-element digests:"+  case quantile 0.5 tdM of+    Just m -> putStrLn $ "  median=" ++ showFFloat6 m ++ " (expected ~0.5)"+    Nothing -> putStrLn "  median=N/A"+  case quantile 0.99 tdM of+    Just p -> putStrLn $ "  p99   =" ++ showFFloat6 p ++ " (expected ~0.99)"+    Nothing -> putStrLn "  p99   =N/A"+  putStrLn $ "  centroids=" ++ show (centroidCount tdM)++  putStrLn ""+  putStrLn $+    "Merge total weight: "+      ++ show (totalWeight tdM)+      ++ " (expected "+      ++ show (totalWeight td1 + totalWeight td2)+      ++ ")"+  putStrLn ""+  putStrLn "Done."
+ benchmarks/Main.hs view
@@ -0,0 +1,290 @@+-- Benchmark / asymptotic-behavior tests for the Haskell t-digest implementation.++module Main where++import Data.IORef+import Data.Maybe (fromMaybe)+import Data.Sketch.TDigest+import System.CPUTime+import Text.Printf++-- ---------------------------------------------------------------------------+-- Helpers+-- ---------------------------------------------------------------------------++getCPUTimeMs :: IO Double+getCPUTimeMs = do+  t <- getCPUTime+  return (fromIntegral t / 1e9) -- picoseconds -> milliseconds++timeBlock :: IO a -> IO (Double, a)+timeBlock action = do+  t0 <- getCPUTimeMs+  result <- action+  -- Force evaluation+  t1 <- result `seq` getCPUTimeMs+  return (t1 - t0, result)++timeBlock_ :: IO () -> IO Double+timeBlock_ action = do+  (ms, _) <- timeBlock action+  return ms++data TestState = TestState {passCount :: !Int, failCount :: !Int}++newState :: TestState+newState = TestState 0 0++addPass :: IORef TestState -> String -> IO ()+addPass ref label = do+  s <- readIORef ref+  writeIORef ref (s {passCount = passCount s + 1})+  printf "  %s  PASS\n" label++addFail :: IORef TestState -> String -> IO ()+addFail ref label = do+  s <- readIORef ref+  writeIORef ref (s {failCount = failCount s + 1})+  printf "  %s  FAIL\n" label++check :: IORef TestState -> String -> Bool -> IO ()+check ref label True = addPass ref label+check ref label False = addFail ref label++ratioOk :: Double -> Double -> Bool+ratioOk ratio expected = ratio >= expected * 0.5 && ratio <= expected * 3.0++ratioOkWide :: Double -> Double -> Bool+ratioOkWide ratio expected = ratio >= expected * 0.2 && ratio <= expected * 5.0++-- Build a t-digest from n uniform values+buildDigest :: Double -> Int -> TDigest+buildDigest delta n =+  let vals = map (\i -> fromIntegral i / fromIntegral n) [0 .. n - 1]+   in foldl' (flip add) (emptyWith delta) vals++main :: IO ()+main = do+  ref <- newIORef newState++  putStrLn "=== T-Digest Asymptotic Behavior Tests (Haskell) ==="+  putStrLn ""++  -- -----------------------------------------------------------------------+  -- Test 1: add() is amortized O(1)+  -- -----------------------------------------------------------------------+  putStrLn "--- Test 1: add() is amortized O(1) ---"++  let sizes = [1000, 10000, 100000, 1000000] :: [Int]+  times <-+    mapM+      ( \n -> do+          let go 0 td = td+              go i td = go (i - 1) (add (fromIntegral i / fromIntegral n) td)+          (ms, _) <- timeBlock (return $! go n (emptyWith 100))+          printf "  N=%-9d  time=%.1fms\n" n ms+          return ms+      )+      sizes++  mapM_+    ( \i -> do+        let expected = fromIntegral (sizes !! i) / fromIntegral (sizes !! (i - 1)) :: Double+            ratio = (times !! i) / (times !! (i - 1))+        check+          ref+          (printf "N=%d  ratio=%.2f (expected ~%.1f)" (sizes !! i) ratio expected)+          (ratioOk ratio expected)+    )+    [1 .. length sizes - 1]++  putStrLn ""++  -- -----------------------------------------------------------------------+  -- Test 2: Centroid count bounded by O(delta)+  -- -----------------------------------------------------------------------+  putStrLn "--- Test 2: Centroid count bounded by O(delta) ---"++  let delta = 100 :: Double+  mapM_+    ( \n -> do+        let td = buildDigest delta n+            cc = centroidCount td+        check+          ref+          (printf "N=%-9d  centroids=%-4d  (delta=%.0f, limit=%d)" n cc delta (5 * round delta :: Int))+          (cc <= 5 * round delta)+    )+    sizes++  putStrLn ""++  -- -----------------------------------------------------------------------+  -- Test 3: Query time independent of N+  -- -----------------------------------------------------------------------+  putStrLn "--- Test 3: Query time independent of N ---"++  let querySizes = [1000, 10000, 100000] :: [Int]+  queryTimes <-+    mapM+      ( \n -> do+          let td = compress (buildDigest 100 n)+              iterations = 10000 :: Int+          (ms, _) <-+            timeBlock+              ( return $!+                  foldl'+                    ( \acc _i ->+                        let q = fromMaybe 0 (quantile 0.5 td)+                            c = fromMaybe 0 (cdf 0.5 td)+                         in acc + q + c+                    )+                    (0 :: Double)+                    [1 .. iterations]+              )+          let usPerQuery = (ms * 1000.0) / fromIntegral iterations+          printf "  N=%-9d  query_time=%.2fus\n" n usPerQuery+          return usPerQuery+      )+      querySizes++  mapM_+    ( \i -> do+        let ratio = (queryTimes !! i) / (queryTimes !! (i - 1))+        check+          ref+          (printf "N=%d  ratio=%.2f (expected ~1.0)" (querySizes !! i) ratio)+          (ratioOkWide ratio 1.0)+    )+    [1 .. length querySizes - 1]++  putStrLn ""++  -- -----------------------------------------------------------------------+  -- Test 4: Tail accuracy improves with delta+  -- -----------------------------------------------------------------------+  putStrLn "--- Test 4: Tail accuracy improves with delta ---"++  let deltas = [50, 100, 200] :: [Double]+      tailQs = [0.01, 0.001, 0.99, 0.999] :: [Double]+      nAcc = 100000 :: Int++  mapM_+    ( \q -> do+        errors <-+          mapM+            ( \d -> do+                let td = buildDigest d nAcc+                    est = fromMaybe 0 (quantile q td)+                    err = abs (est - q)+                printf "  delta=%-5.0f  q=%-6.3f  error=%.6f\n" d q err+                return err+            )+            deltas++        mapM_+          ( \i -> do+              let ok = (errors !! i) <= (errors !! (i - 1)) * 1.5 + 0.001+              check+                ref+                ( printf+                    "delta=%.0f q=%.3f error decreases (%.6f <= %.6f)"+                    (deltas !! i)+                    q+                    (errors !! i)+                    (errors !! (i - 1))+                )+                ok+          )+          [1 .. length deltas - 1]+    )+    tailQs++  putStrLn ""++  -- -----------------------------------------------------------------------+  -- Test 5: Merge preserves weight and accuracy+  -- -----------------------------------------------------------------------+  putStrLn "--- Test 5: Merge preserves weight and accuracy ---"++  let nMerge = 10000 :: Int+      td1_0 =+        foldl'+          (\td i -> add (fromIntegral i / fromIntegral nMerge) td)+          (emptyWith 100)+          [0 .. nMerge `div` 2 - 1]+      td2_0 =+        foldl'+          (\td i -> add (fromIntegral i / fromIntegral nMerge) td)+          (emptyWith 100)+          [nMerge `div` 2 .. nMerge - 1]+      wBefore = totalWeight td1_0 + totalWeight td2_0+      merged = merge td1_0 td2_0+      wAfter = totalWeight merged++  check+    ref+    (printf "weight_before=%.0f  weight_after=%.0f  (equal)" wBefore wAfter)+    (abs (wBefore - wAfter) < 1e-9)++  let medianEst = fromMaybe 0 (quantile 0.5 merged)+      medianErr = abs (medianEst - 0.5)+  check+    ref+    (printf "median_error=%.6f  (< 0.05)" medianErr)+    (medianErr < 0.05)++  let p99Est = fromMaybe 0 (quantile 0.99 merged)+      p99Err = abs (p99Est - 0.99)+  check+    ref+    (printf "p99_error=%.6f  (< 0.05)" p99Err)+    (p99Err < 0.05)++  putStrLn ""++  -- -----------------------------------------------------------------------+  -- Test 6: compress is O(n log n)+  -- -----------------------------------------------------------------------+  putStrLn "--- Test 6: compress is O(n log n) ---"++  let compressSizes = [500, 5000, 50000] :: [Int]+  compressTimes <-+    mapM+      ( \bufN -> do+          let buf =+                map+                  ( \i ->+                      let v = fromIntegral i / fromIntegral bufN+                       in v+                  )+                  [0 .. bufN - 1]+              td0 = foldl' (flip add) (emptyWith 10000) buf+          (ms, _) <- timeBlock (return $! centroidCount (compress td0))+          printf "  buf_n=%-8d  compress_time=%.2fms\n" bufN ms+          return ms+      )+      compressSizes++  mapM_+    ( \i -> do+        let n0 = fromIntegral (compressSizes !! (i - 1)) :: Double+            n1 = fromIntegral (compressSizes !! i) :: Double+            expected = (n1 * logBase 2 n1) / (n0 * logBase 2 n0)+            ratio = (compressTimes !! i) / (compressTimes !! (i - 1))+            ok = ratio >= expected * 0.3 && ratio <= expected * 4.0+        check+          ref+          (printf "buf_n=%d  ratio=%.2f (expected ~%.1f)" (compressSizes !! i) ratio expected)+          ok+    )+    [1 .. length compressSizes - 1]++  putStrLn ""++  -- -----------------------------------------------------------------------+  -- Summary+  -- -----------------------------------------------------------------------+  s <- readIORef ref+  let total = passCount s + failCount s+  printf "Summary: %d/%d tests passed\n" (passCount s) total
+ dunning-t-digest.cabal view
@@ -0,0 +1,77 @@+cabal-version:   3.0+name:            dunning-t-digest+version:         0.1.0.0+synopsis:        Dunning t-digest for online quantile estimation+description:+  A pure functional implementation of the Dunning t-digest data structure+  (merging digest variant, K1 arcsine scale function) using finger trees+  with four-component monoidal measures for O(log n) insertion and queries.+  .+  Also provides a mutable variant backed by mutable vectors in the ST monad.+  .+  The t-digest provides streaming, mergeable, memory-bounded approximation+  of quantile (percentile) queries with high accuracy in the tails.+  .+  Features:+  .+  * O(log n) insertion via split-by-mean (no buffering needed)+  * O(log n) quantile queries via split-by-cumulative-weight+  * O(log n) CDF queries via split-by-mean+  * O(δ log n) compression via split-based greedy merge+  * O(1) total weight, centroid count, and chunk mean computation+  * Mutable variant with O(1) amortized insertion via buffering++license:         BSD-3-Clause+license-file:    LICENSE+author:          Nadia Yvette Chambers+maintainer:      nadia.yvette.chambers@gmail.com+copyright:       (c) 2025 Nadia Yvette Chambers+category:        Data, Statistics+build-type:      Simple+extra-doc-files: CHANGELOG.md+tested-with:     GHC == 9.14.1++source-repository head+  type:     git+  location: https://github.com/NadiaYvette/t-digest.git+  subdir:   haskell++common warnings+  ghc-options: -Wall++library+  import:           warnings+  exposed-modules:+    Data.Sketch.TDigest+    Data.Sketch.TDigest.Mutable++  build-depends:+    , base         >= 4.16 && < 5+    , fingertree   >= 0.1  && < 0.2+    , vector       >= 0.12 && < 0.14++  hs-source-dirs:   src+  default-extensions:+    MultiParamTypeClasses+    RankNTypes+  default-language:   Haskell2010++executable dunning-t-digest-demo+  import:           warnings+  main-is:          Main.hs+  build-depends:+    , base              >= 4.16 && < 5+    , dunning-t-digest++  hs-source-dirs:   app+  default-language: Haskell2010++executable dunning-t-digest-bench+  import:           warnings+  main-is:          Main.hs+  build-depends:+    , base              >= 4.16 && < 5+    , dunning-t-digest++  hs-source-dirs:   benchmarks+  default-language: Haskell2010
+ src/Data/Sketch/TDigest.hs view
@@ -0,0 +1,943 @@+-- |+-- Module      : Data.Sketch.TDigest+-- Description : Dunning t-digest for online quantile estimation+-- Copyright   : (c) Nadia Yvette Chambers, 2025+-- License     : BSD-3-Clause+-- Maintainer  : nadia.yvette.chambers@gmail.com+-- Stability   : experimental+--+-- A pure functional implementation of the Dunning t-digest data structure,+-- using the merging digest variant with the \(K_1\) (arcsine) scale function.+-- The t-digest provides streaming, mergeable, memory-bounded approximation+-- of quantile (percentile) queries with high accuracy in the tails.+--+-- == Background+--+-- The /streaming quantile problem/ asks: given a (possibly unbounded) stream+-- of real-valued observations, answer queries of the form "what is the value+-- at the \(q\)-th quantile?" using bounded memory.+-- Munro & Paterson (1980) established that exact selection from a stream of+-- \(n\) elements requires \(\Omega(n)\) space in the comparison model+-- (<https://doi.org/10.1016/0304-3975(80)90061-4>), so any sub-linear space+-- algorithm must accept approximation.  Greenwald & Khanna (2001) gave the+-- first \(\varepsilon\)-approximate streaming quantile summary with space+-- \(O\!\bigl(\frac{1}{\varepsilon}\log(\varepsilon n)\bigr)\)+-- (<https://doi.org/10.1145/375663.375670>), guaranteeing uniform error across+-- all quantiles.  The t-digest takes a different approach: it trades uniform+-- guarantees for much higher accuracy in the extreme tails (\(q \approx 0\) or+-- \(q \approx 1\)), which is the regime most relevant to SLA monitoring,+-- anomaly detection, and financial risk measurement.+--+-- == The t-digest+--+-- The t-digest, introduced by Ted Dunning+-- (<https://doi.org/10.1016/j.simpa.2020.100049>; see also Dunning & Ertl,+-- <https://arxiv.org/abs/1902.04023>), represents an empirical distribution as+-- an ordered sequence of /centroids/ \((m_i, w_i)\), where \(m_i\) is a+-- weighted mean and \(w_i\) is a count of observations.  Centroids are kept+-- sorted by mean.  The key idea is to use a /scale function/ \(k(q, \delta)\)+-- that maps the quantile axis \([0, 1]\) to a "scale space" in which uniform+-- spacing corresponds to the desired non-uniform resolution in quantile space.+--+-- This module implements the /merging digest/ variant with the \(K_1\)+-- (arcsine) scale function:+--+-- \[+--   k(q, \delta) \;=\; \frac{\delta}{2\pi}\,\arcsin(2q - 1)+-- \]+--+-- The \(K_1\) function has infinite derivative at \(q = 0\) and \(q = 1\),+-- meaning it allocates proportionally more centroids near the tails.  Its+-- inverse is:+--+-- \[+--   q(k, \delta) \;=\; \frac{1 + \sin\!\bigl(\frac{2\pi k}{\delta}\bigr)}{2}+-- \]+--+-- A new observation may be merged into an existing centroid \(i\) only if the+-- resulting centroid would satisfy the /size constraint/:+--+-- \[+--   k\!\bigl(q_{\mathrm{upper}},\, \delta\bigr) \;-\; k\!\bigl(q_{\mathrm{lower}},\, \delta\bigr) \;\le\; 1+-- \]+--+-- where \(q_{\mathrm{lower}}\) and \(q_{\mathrm{upper}}\) are the quantile+-- boundaries of the (proposed) merged centroid.  This constraint ensures that+-- centroids near \(q = 0\) and \(q = 1\) remain small (even singletons),+-- while centroids near the median may absorb many observations.+--+-- == Space bounds+--+-- The number of centroids in a t-digest is bounded by \(O(\delta)\)+-- /regardless/ of the number of observations \(n\).  Specifically, the integer+-- range of the scale function is+-- \(\lceil k(0,\delta)\rceil \ldots \lfloor k(1,\delta)\rfloor =+-- \lceil -\delta/2\rceil \ldots \lfloor \delta/2\rfloor\),+-- giving at most \(\delta + 1\) unit intervals and therefore at most+-- \(\delta + 1\) centroids after compression.  In practice the compression+-- threshold is set to \(3\delta\) centroids (before triggering a compress+-- pass), so the working-set size is at most \(3\delta\) centroids.  With the+-- default \(\delta = 100\), this means at most 300 centroids regardless of+-- whether the stream contains \(10^3\) or \(10^{12}\) observations.+--+-- == Implementation: finger trees with a four-component measure+--+-- This module stores centroids in a @'Data.FingerTree.FingerTree'@ from the+-- @fingertree@ package, as described by Hinze & Paterson (2006)+-- (<https://doi.org/10.1017/S0956796805005769>).  Finger trees support+-- amortised \(O(\log n)\) split and concatenation, and \(O(1)\) access to+-- extremal elements, making them well suited for the sorted-centroid+-- representation.+--+-- The monoidal measure carried by the tree has four components:+--+-- 1. @mWeight@ \(= \sum w_i\): cumulative weight, enabling split-by-weight+--    for quantile queries.+-- 2. @mCount@ \(= |\{i\}|\): centroid count, enabling \(O(1)\)+--    'centroidCount'.+-- 3. @mMaxMean@ \(= \max\{m_i\}\): maximum mean over the subtree, enabling+--    split-by-mean for insertion and CDF queries.  Because centroids are+--    stored in sorted order, @mMaxMean@ is monotone over prefixes.+-- 4. @mMeanWeightSum@ \(= \sum m_i w_i\): the sum of products of mean and+--    weight.  This enables \(O(1)\) computation of the merged mean of any+--    contiguous chunk: \(\bar{m} = \texttt{mMeanWeightSum} /+--    \texttt{mWeight}\).  This is the key to achieving \(O(\delta \log n)\)+--    compression: each of the \(O(\delta)\) chunks produced by splitting at+--    scale-function unit boundaries can be collapsed into a single centroid+--    without traversing its elements.+--+-- == Companion implementations: array-backed 2-3-4 trees+--+-- Twenty-two mutable implementations in this project (in C, C++, Rust, Go,+-- Zig, Java, C#, and others) use array-backed 2-3-4 trees instead of+-- finger trees.  The 2-3-4 tree is a B-tree of order 4 (Bayer & McCreight,+-- 1972; <https://doi.org/10.1007/BF00288683>), isomorphic to a red-black tree+-- via the correspondence established by Guibas & Sedgewick (1978)+-- (<https://doi.org/10.1109/SFCS.1978.3>; see also Sedgewick, 2008,+-- <https://sedgewick.io/wp-content/themes/flavor/papers/2008LLRB.pdf>),+-- provides worst-case \(O(\log n)\) insertion, deletion, and search with+-- excellent cache locality when nodes are packed into a flat array.  This is+-- particularly important for robustness at very fine-grained quantile queries+-- (e.g., \(q = 0.9999\)) where the tail centroids that determine accuracy+-- must be located quickly and updated with minimal overhead.  The array-backed+-- layout avoids pointer-chasing and improves branch-prediction behaviour,+-- yielding 2--5\(\times\) speedups in practice over pointer-based trees.+--+-- == Quick start+--+-- @+-- import Data.Sketch.TDigest+-- import Data.List ('Data.List.foldl\'')+--+-- main :: IO ()+-- main = do+--   let td = 'Data.List.foldl\'' (flip 'add') 'empty' [1.0 .. 10000.0]+--   print ('quantile' 0.99 td)   -- Just ~9900.5+--   print ('cdf' 5000.0 td)      -- Just ~0.5+-- @+module Data.Sketch.TDigest+  ( -- * Types+    TDigest,+    Centroid (..),++    -- * Construction+    empty,+    emptyWith,++    -- * Insertion+    add,+    addWeighted,++    -- * Compression+    compress,++    -- * Queries+    quantile,+    cdf,++    -- * Merging+    merge,++    -- * Accessors+    totalWeight,+    centroidCount,+    centroidList,+    getDelta,+    getMin,+    getMax,++    -- * Reconstruction+    fromComponents,+  )+where++import Data.FingerTree (FingerTree, Measured (..), ViewL (..), ViewR (..), (<|), (|>))+import qualified Data.FingerTree as FT++-- ---------------------------------------------------------------------------+-- Measure (monoidal annotation for the finger tree)+-- ---------------------------------------------------------------------------++-- | Monoidal measure carried by every internal node of the finger tree.+--+-- Following Hinze & Paterson (2006)+-- (<https://doi.org/10.1017/S0956796805005769>), a finger tree is+-- parameterised by a monoid whose cached values enable efficient splitting.+-- The t-digest requires /four/ independent capabilities from the tree, so the+-- measure is a four-component product monoid:+--+-- * @mWeight@ — cumulative weight \(\sum w_i\).  Used by 'quantile' to+--   split the tree at a target cumulative weight in \(O(\log n)\).+--+-- * @mCount@ — number of centroids \(|\{i\}|\).  Provides \(O(1)\)+--   'centroidCount' and is used during quantile interpolation to detect+--   boundary centroids.+--+-- * @mMaxMean@ — maximum centroid mean \(\max\{m_i\}\) in the subtree.+--   Because centroids are sorted by mean, this value is monotone over+--   prefixes, enabling 'FT.split' by mean value for insertion ('addWeighted')+--   and CDF queries ('cdf').+--+-- * @mMeanWeightSum@ — the sum \(\sum m_i w_i\).  Combined with @mWeight@,+--   this allows the weighted mean of any contiguous subtree to be computed in+--   \(O(1)\): \(\bar{m} = \texttt{mMeanWeightSum}\,/\,\texttt{mWeight}\).+--   This is the critical component that makes 'compress' run in+--   \(O(\delta \log n)\) rather than \(O(n)\): each chunk produced by+--   splitting at \(K_1\) unit boundaries is collapsed into a single centroid+--   without iterating over its elements.+data Measure = Measure+  { mWeight :: {-# UNPACK #-} !Double,+    mCount :: {-# UNPACK #-} !Int,+    mMaxMean :: {-# UNPACK #-} !Double,+    mMeanWeightSum :: {-# UNPACK #-} !Double+  }+  deriving (Show)++instance Semigroup Measure where+  (Measure w1 c1 mm1 mws1) <> (Measure w2 c2 mm2 mws2) =+    Measure (w1 + w2) (c1 + c2) (max mm1 mm2) (mws1 + mws2)++instance Monoid Measure where+  mempty = Measure 0 0 (-(1 / 0)) 0++-- ---------------------------------------------------------------------------+-- Types+-- ---------------------------------------------------------------------------++-- | A single centroid in the t-digest, representing a cluster of nearby+-- values by their weighted mean and total weight.+--+-- In the t-digest framework (Dunning, 2021;+-- <https://doi.org/10.1016/j.simpa.2020.100049>), the empirical distribution+-- is approximated by an ordered sequence of centroids \((m_i, w_i)\).  When a+-- new observation \(x\) with weight \(w\) is merged into an existing centroid+-- \((m_i, w_i)\), the weighted mean update rule is applied:+--+-- \[+--   m_i' \;=\; \frac{m_i \, w_i \;+\; x \, w}{w_i + w},+--   \qquad+--   w_i' \;=\; w_i + w+-- \]+--+-- This is the standard incremental weighted mean, which is exact in+-- floating-point arithmetic up to the usual rounding.  Note that the centroid+-- does /not/ store individual observations — only the summary statistics+-- \((m_i, w_i)\) are retained, which is what gives the t-digest its bounded+-- space.+data Centroid = Centroid+  { -- | Weighted mean of all values merged into this centroid.+    cMean :: {-# UNPACK #-} !Double,+    -- | Total weight (count) of values in this centroid.  For unweighted+    -- streams, this is simply the number of observations that have been+    -- merged into this centroid.+    cWeight :: {-# UNPACK #-} !Double+  }+  deriving (Show)++instance Measured Measure Centroid where+  measure c = Measure (cWeight c) 1 (cMean c) (cMean c * cWeight c)++-- | The t-digest data structure for online quantile estimation.+--+-- Internally, a t'TDigest' consists of:+--+-- * A 'FingerTree' of t'Centroid's, sorted by mean.  The tree carries the+--   four-component @Measure@ described above, enabling \(O(\log n)\) split+--   operations by both mean and cumulative weight.+--+-- * Cached metadata: the total weight \(N = \sum w_i\), the global minimum+--   and maximum of all observed values, the compression parameter \(\delta\),+--   and the compression threshold \(3\delta\).+--+-- __Invariants:__+--+-- 1. Centroids are sorted in non-decreasing order of 'cMean'.+-- 2. @tdTotalWeight@ equals @mWeight (measure tdCentroids)@ and equals the+--    sum of all 'cWeight' values.+-- 3. @tdMin@ \(\le m_1\) and @tdMax@ \(\ge m_k\) (where \(k\) is the number+--    of centroids), with equality in the singleton case.+-- 4. After 'compress', every centroid satisfies the \(K_1\) size constraint:+--    \(k(q_{\mathrm{upper}}, \delta) - k(q_{\mathrm{lower}}, \delta) \le 1\),+--    where \(q_{\mathrm{lower}}\) and \(q_{\mathrm{upper}}\) are the+--    normalised cumulative weight boundaries of the centroid.+-- 5. The centroid count never exceeds \(3\delta\) for sustained periods;+--    insertions that push the count above this threshold trigger an automatic+--    'compress' pass.+data TDigest = TDigest+  { tdCentroids :: !(FingerTree Measure Centroid),+    tdTotalWeight :: !Double,+    tdMin :: !Double,+    tdMax :: !Double,+    tdDelta :: !Double,+    tdMaxCentroids :: {-# UNPACK #-} !Int+  }+  deriving (Show)++-- ---------------------------------------------------------------------------+-- Construction+-- ---------------------------------------------------------------------------++-- | Create an empty t-digest with the default compression parameter+-- \(\delta = 100\).+--+-- This is a good starting point for most applications.  With \(\delta = 100\),+-- the digest will use at most 300 centroids (the compression threshold is+-- \(3\delta\)), occupying roughly 4.8 KB of centroid data.  Empirically, this+-- yields quantile errors below \(10^{-4}\) at the median and below+-- \(10^{-6}\) for \(q < 0.01\) or \(q > 0.99\)+-- (Dunning & Ertl, 2019; <https://arxiv.org/abs/1902.04023>).+empty :: TDigest+empty = emptyWith 100++-- | Create an empty t-digest with a given compression parameter \(\delta\).+--+-- The compression parameter controls the trade-off between accuracy and space:+--+-- * __Larger \(\delta\)__ (e.g., 200–500) means more centroids are retained,+--   giving higher accuracy — especially at extreme quantiles — at the cost of+--   more memory and slower queries.+-- * __Smaller \(\delta\)__ (e.g., 20–50) means fewer centroids, saving+--   memory but increasing quantile estimation error.+--+-- The maximum number of centroids after compression is \(\delta + 1\)+-- (one per integer unit in the range of \(K_1\)), and the compression+-- threshold (the point at which automatic compression is triggered during+-- insertion) is set to \(\lceil 3\delta \rceil\).  Typical values used in+-- production systems are \(\delta \in [50, 300]\).+--+-- Setting \(\delta \le 0\) is not meaningful and will result in a digest that+-- compresses aggressively to zero or one centroid.+emptyWith :: Double -> TDigest+emptyWith delta =+  TDigest+    { tdCentroids = FT.empty,+      tdTotalWeight = 0,+      tdMin = 1 / 0,+      tdMax = -(1 / 0),+      tdDelta = delta,+      tdMaxCentroids = ceiling (delta * 3)+    }++-- ---------------------------------------------------------------------------+-- Scale function K_1+-- ---------------------------------------------------------------------------++-- | The \(K_1\) (arcsine) scale function:+--+-- \[+--   k(q, \delta) \;=\; \frac{\delta}{2\pi}\,\arcsin(2q - 1)+-- \]+--+-- This function maps the quantile domain \([0, 1]\) to the "scale space"+-- \([-\delta/2,\; \delta/2]\).  Its derivative+-- \(k'(q) = \frac{\delta}{\pi\sqrt{q(1-q)}}\) diverges at \(q = 0\) and+-- \(q = 1\), causing centroids near the tails to be allocated much more+-- finely than centroids near the median — which is the defining feature of+-- the t-digest's accuracy profile.+kScale :: Double -> Double -> Double+kScale delta q = (delta / (2 * pi)) * asin (2 * q - 1)++-- | Inverse of the \(K_1\) scale function:+--+-- \[+--   q(k, \delta) \;=\; \frac{1 + \sin\!\bigl(\frac{2\pi k}{\delta}\bigr)}{2}+-- \]+--+-- Used during 'compress' to compute the quantile boundaries corresponding to+-- integer scale-function values, i.e., the boundaries of the unit intervals+-- in scale space.+kScaleInv :: Double -> Double -> Double+kScaleInv delta k = (1 + sin (2 * pi * k / delta)) / 2++-- ---------------------------------------------------------------------------+-- FingerTree helpers+-- ---------------------------------------------------------------------------++ftToList :: FingerTree Measure Centroid -> [Centroid]+ftToList ft = case FT.viewl ft of+  EmptyL -> []+  x :< rest -> x : ftToList rest++splitByMean :: Double -> FingerTree Measure Centroid -> (FingerTree Measure Centroid, FingerTree Measure Centroid)+splitByMean x = FT.split (\m -> mMaxMean m >= x)++-- ---------------------------------------------------------------------------+-- Adding values+-- ---------------------------------------------------------------------------++-- | Add a single value with weight 1 to the digest.+--+-- \(O(\log n)\) amortised, where \(n\) is the number of centroids.+-- Equivalent to @'addWeighted' x 1@.+add :: Double -> TDigest -> TDigest+add x = addWeighted x 1++-- | Add a value \(x\) with a given weight \(w\) to the digest.+--+-- The algorithm proceeds as follows:+--+-- 1. __Split__ the finger tree at the insertion point using+--    @'FT.split' (\m -> mMaxMean m >= x)@, yielding a left subtree (all+--    centroids with mean \(< x\)) and a right subtree (mean \(\ge x\)).+--    This is \(O(\log n)\) by the finger tree split theorem+--    (Hinze & Paterson, 2006; <https://doi.org/10.1017/S0956796805005769>).+--+-- 2. __Find nearest neighbour:__ examine the rightmost centroid of the left+--    subtree and the leftmost centroid of the right subtree.  For each+--    candidate neighbour \((m_i, w_i)\), compute the proposed merged weight+--    \(w_i + w\) and check the \(K_1\) scale-function constraint:+--+--    \[+--      k\!\bigl(q_{\mathrm{upper}},\, \delta\bigr)+--      \;-\; k\!\bigl(q_{\mathrm{lower}},\, \delta\bigr)+--      \;\le\; 1+--    \]+--+--    where \(q_{\mathrm{lower}}\) and \(q_{\mathrm{upper}}\) are the+--    normalised cumulative weight boundaries of the proposed merged centroid.+--+-- 3. __Merge or insert:__ if one or both neighbours can absorb the new value,+--    merge with the /closer/ one (by distance \(|m_i - x|\)) using the+--    weighted mean update rule.  If neither can absorb it (because doing so+--    would violate the size constraint), insert a new singleton centroid+--    \((x, w)\) into the tree.+--+-- 4. __Auto-compress:__ if the centroid count exceeds the threshold+--    \(3\delta\), trigger a 'compress' pass.+--+-- The overall amortised cost is \(O(\log n)\), dominated by the finger tree+-- split and concatenation.+addWeighted :: Double -> Double -> TDigest -> TDigest+addWeighted x w td =+  let n = tdTotalWeight td + w+      newMin = min x (tdMin td)+      newMax = max x (tdMax td)+      delta = tdDelta td+      cs = tdCentroids td+      newC = Centroid x w+      td' =+        if FT.null cs+          then+            td+              { tdCentroids = FT.singleton newC,+                tdTotalWeight = n,+                tdMin = newMin,+                tdMax = newMax+              }+          else+            let (left, right) = splitByMean x cs+                leftWeight = mWeight (FT.measure left)+                result = tryMergeNeighbor delta n leftWeight left right newC+             in td+                  { tdCentroids = result,+                    tdTotalWeight = n,+                    tdMin = newMin,+                    tdMax = newMax+                  }+   in if mCount (FT.measure (tdCentroids td')) > tdMaxCentroids td'+        then compress td'+        else td'++-- | Try to merge with nearest neighbor; insert if neither allows merging.+tryMergeNeighbor ::+  Double ->+  Double ->+  Double ->+  FingerTree Measure Centroid ->+  FingerTree Measure Centroid ->+  Centroid ->+  FingerTree Measure Centroid+tryMergeNeighbor delta n leftWeight left right newC =+  let x = cMean newC+      k = kScale delta++      leftNeighbor = case FT.viewr left of+        EmptyR -> Nothing+        leftRest :> lc ->+          let cumBefore = mWeight (FT.measure leftRest)+              proposed = cWeight lc + cWeight newC+              q0 = cumBefore / n+              q1 = (cumBefore + proposed) / n+              canMerge = k q1 - k q0 <= 1.0+              dist = abs (cMean lc - x)+           in if canMerge then Just (leftRest, lc, dist) else Nothing++      rightNeighbor = case FT.viewl right of+        EmptyL -> Nothing+        rc :< rightRest ->+          let proposed = cWeight rc + cWeight newC+              q0 = leftWeight / n+              q1 = (leftWeight + proposed) / n+              canMerge = k q1 - k q0 <= 1.0+              dist = abs (cMean rc - x)+           in if canMerge then Just (rightRest, rc, dist) else Nothing+   in case (leftNeighbor, rightNeighbor) of+        (Just (leftRest, lc, ldist), Just (rightRest, rc, rdist))+          | ldist <= rdist ->+              (leftRest |> mergeCentroid lc newC) FT.>< right+          | otherwise ->+              left FT.>< (mergeCentroid rc newC <| rightRest)+        (Just (leftRest, lc, _), Nothing) ->+          (leftRest |> mergeCentroid lc newC) FT.>< right+        (Nothing, Just (rightRest, rc, _)) ->+          left FT.>< (mergeCentroid rc newC <| rightRest)+        (Nothing, Nothing) ->+          left FT.>< (newC <| right)++-- | Merge two centroids using the weighted mean update rule:+--+-- \[+--   m' = \frac{m_a \, w_a + m_b \, w_b}{w_a + w_b},+--   \qquad+--   w' = w_a + w_b+-- \]+mergeCentroid :: Centroid -> Centroid -> Centroid+mergeCentroid a b =+  let w = cWeight a + cWeight b+      m = (cMean a * cWeight a + cMean b * cWeight b) / w+   in Centroid m w++-- ---------------------------------------------------------------------------+-- Compression (split-based greedy merge)+-- ---------------------------------------------------------------------------++-- | Compress the digest by merging centroids that fall within the same+-- \(K_1\) scale-function unit interval.+--+-- The compression algorithm works as follows:+--+-- 1. Compute the integer range of the \(K_1\) scale function:+--    \(j_{\min} = \lceil k(0, \delta) \rceil = \lceil -\delta/2 \rceil\) and+--    \(j_{\max} = \lfloor k(1, \delta) \rfloor = \lfloor \delta/2 \rfloor\).+--+-- 2. For each integer \(j \in \{j_{\min}+1, \ldots, j_{\max}\}\), compute the+--    cumulative weight boundary \(b_j = k^{-1}(j, \delta) \cdot N\), where+--    \(N\) is the total weight.+--+-- 3. Split the finger tree at each boundary \(b_j\) by cumulative weight+--    (using @'FT.split' (\m -> mWeight m > b_j)@), yielding \(O(\delta)\)+--    contiguous chunks.+--+-- 4. Collapse each chunk into a single centroid using the @mMeanWeightSum@+--    and @mWeight@ components of the monoidal measure:+--    \(\bar{m} = \texttt{mMeanWeightSum}\,/\,\texttt{mWeight}\).  This is+--    \(O(1)\) per chunk — no traversal of individual centroids is needed.+--+-- __Complexity:__ \(O(\delta \log n)\), because there are \(O(\delta)\) split+-- operations, each costing \(O(\log n)\) where \(n\) is the pre-compression+-- centroid count.  After compression, the centroid count is at most+-- \(\delta + 1\).+compress :: TDigest -> TDigest+compress td+  | cnt <= 1 = td+  | otherwise =+      let n = tdTotalWeight td+          delta = tdDelta td+          cs = tdCentroids td+          -- K1 range: k(0) = -delta/2, k(1) = delta/2+          -- Integer unit boundaries from ceil(k(0)) to floor(k(1))+          kMin = kScale delta 0 -- = -delta/2+          kMax = kScale delta 1 -- = +delta/2+          jMin = ceiling kMin :: Int+          jMax = floor kMax :: Int+          -- Build boundaries: q values at each integer k-value+          boundaries = [kScaleInv delta (fromIntegral j) * n | j <- [jMin + 1 .. jMax]]+          -- Split-and-merge at each boundary+          merged = splitMerge boundaries cs+       in td {tdCentroids = merged}+  where+    cnt = mCount (FT.measure (tdCentroids td))++-- | Split a finger tree at cumulative weight boundaries and merge each+-- chunk into a single centroid.  This is the inner loop of 'compress'.+--+-- The function walks through the list of weight boundaries, performing+-- an @'FT.split'@ at each one.  Each resulting chunk (a contiguous sub-tree+-- of centroids whose combined weight falls within a single \(K_1\) unit+-- interval) is collapsed via 'mergeChunk' into a single centroid and appended+-- to the accumulator.+splitMerge :: [Double] -> FingerTree Measure Centroid -> FingerTree Measure Centroid+splitMerge boundaries tree = go boundaries tree FT.empty+  where+    go [] remaining acc =+      -- Last chunk: everything remaining+      case mergeChunk remaining of+        Nothing -> acc+        Just c -> acc |> c+    go (b : bs) remaining acc =+      let (chunk, rest) = FT.split (\m -> mWeight m > b) remaining+       in case mergeChunk chunk of+            Nothing -> go bs rest acc+            Just c -> go bs rest (acc |> c)++-- | Merge all centroids in a finger tree chunk into a single centroid+-- using the monoidal measure.  Runs in \(O(1)\) — no traversal of+-- individual centroids is needed, because the measure already caches+-- \(\sum w_i\) and \(\sum m_i w_i\).+mergeChunk :: FingerTree Measure Centroid -> Maybe Centroid+mergeChunk ft+  | w == 0 = Nothing+  | otherwise = Just (Centroid (mws / w) w)+  where+    m = FT.measure ft+    w = mWeight m+    mws = mMeanWeightSum m++-- ---------------------------------------------------------------------------+-- Quantile estimation+-- ---------------------------------------------------------------------------++-- | Estimate the value at quantile \(q\) (\(0 \le q \le 1\)).+--+-- The algorithm uses an interpolation scheme that treats each centroid as+-- representing a point mass at its mean, spread uniformly over a weight+-- interval centred at the centroid's cumulative midpoint.  Between+-- consecutive centroid midpoints, the estimated quantile function is linearly+-- interpolated:+--+-- \[+--   \hat{x}(q) \;=\; m_i + \frac{q \cdot N - \mathrm{mid}_i}+--   {\mathrm{mid}_{i+1} - \mathrm{mid}_i} \cdot (m_{i+1} - m_i)+-- \]+--+-- where \(\mathrm{mid}_i = \sum_{j<i} w_j + w_i/2\) is the cumulative+-- midpoint of centroid \(i\), and \(N = \sum w_j\).+--+-- __Boundary handling:__ for the leftmost centroid, if \(q \cdot N\) falls+-- below \(w_1 / 2\), the function interpolates between the global minimum+-- (@tdMin@) and \(m_1\).  Symmetrically, for the rightmost centroid, it+-- interpolates between \(m_k\) and the global maximum (@tdMax@).  This+-- ensures that 'quantile' returns @tdMin@ at \(q = 0\) and @tdMax@ at+-- \(q = 1\).+--+-- __Complexity:__ \(O(\log n)\) via @'FT.split'@ on cumulative weight,+-- followed by a constant amount of local interpolation work.+--+-- Returns 'Nothing' if the digest is empty.+quantile :: Double -> TDigest -> Maybe Double+quantile q td+  | numCentroids == 0 = Nothing+  | numCentroids == 1 =+      case FT.viewl cs of+        c :< _ -> Just (cMean c)+        EmptyL -> Nothing+  | otherwise = Just (findQuantile (clamp 0 1 q))+  where+    cs = tdCentroids td+    n = tdTotalWeight td+    mn = tdMin td+    mx = tdMax td+    numCentroids = mCount (FT.measure cs)++    findQuantile :: Double -> Double+    findQuantile q' =+      let target = q' * n+          (left, right) = FT.split (\m -> mWeight m > target) cs+          leftWeight = mWeight (FT.measure left)+          leftCount = mCount (FT.measure left)+       in case FT.viewl right of+            EmptyL ->+              case FT.viewr left of+                _ :> lastC -> interpolateRight lastC (leftWeight - cWeight lastC) target+                EmptyR -> mx+            cur :< rightRest ->+              interpolateAt leftCount leftWeight cur left rightRest target++    interpolateAt :: Int -> Double -> Centroid -> FingerTree Measure Centroid -> FingerTree Measure Centroid -> Double -> Double+    interpolateAt i cumulative c left rest target+      | i == 0 && target < cWeight c / 2 =+          if cWeight c == 1+            then mn+            else mn + (cMean c - mn) * (target / (cWeight c / 2))+      | i == numCentroids - 1 =+          if target > n - cWeight c / 2+            then+              if cWeight c == 1+                then mx+                else+                  let remaining = n - cWeight c / 2+                   in cMean c + (mx - cMean c) * ((target - remaining) / (cWeight c / 2))+            else cMean c+      | otherwise =+          let mid = cumulative + cWeight c / 2+           in case FT.viewl rest of+                nextC :< _ ->+                  let nextMid = cumulative + cWeight c + cWeight nextC / 2+                   in if target <= nextMid+                        then+                          let frac =+                                if nextMid == mid+                                  then 0.5+                                  else (target - mid) / (nextMid - mid)+                           in cMean c + frac * (cMean nextC - cMean c)+                        else+                          let newLeft = left FT.>< FT.singleton c+                           in interpolateAt (i + 1) (cumulative + cWeight c) nextC newLeft (ftTail rest) target+                EmptyL -> cMean c++    interpolateRight :: Centroid -> Double -> Double -> Double+    interpolateRight c _cumulative target =+      if target > n - cWeight c / 2+        then+          if cWeight c == 1+            then mx+            else+              let remaining = n - cWeight c / 2+               in cMean c + (mx - cMean c) * ((target - remaining) / (cWeight c / 2))+        else cMean c++    ftTail :: FingerTree Measure Centroid -> FingerTree Measure Centroid+    ftTail ft = case FT.viewl ft of+      EmptyL -> FT.empty+      _ :< r -> r++-- ---------------------------------------------------------------------------+-- CDF estimation+-- ---------------------------------------------------------------------------++-- | Estimate the cumulative distribution function (CDF) at value \(x\),+-- i.e., the fraction of the distribution that lies at or below \(x\).+--+-- The CDF is estimated by piecewise-linear interpolation between centroid+-- midpoints.  For a query point \(x\) falling between the means of+-- consecutive centroids \(m_i\) and \(m_{i+1}\), the estimated CDF is:+--+-- \[+--   \hat{F}(x) \;=\; \frac{1}{N}\left(+--     \mathrm{mid}_i + \frac{x - m_i}{m_{i+1} - m_i}+--     \cdot (\mathrm{mid}_{i+1} - \mathrm{mid}_i)+--   \right)+-- \]+--+-- where \(\mathrm{mid}_i = \sum_{j<i} w_j + w_i/2\).+--+-- __Boundary handling:__ if \(x \le \texttt{tdMin}\) the function returns 0;+-- if \(x \ge \texttt{tdMax}\) it returns 1.  For \(x\) below the first+-- centroid mean or above the last, the function interpolates between the+-- global extreme and the nearest centroid mean, mirroring the boundary+-- treatment in 'quantile'.+--+-- __Complexity:__ \(O(\log n)\) via @'FT.split'@ on the @mMaxMean@ component+-- of the monoidal measure, which locates the pair of centroids straddling+-- the query point without scanning.+--+-- Returns 'Nothing' if the digest is empty.+cdf :: Double -> TDigest -> Maybe Double+cdf x td+  | numCentroids == 0 = Nothing+  | x <= mn = Just 0+  | x >= mx = Just 1+  | otherwise = Just (findCdf x)+  where+    cs = tdCentroids td+    n = tdTotalWeight td+    mn = tdMin td+    mx = tdMax td+    numCentroids = mCount (FT.measure cs)++    findCdf :: Double -> Double+    findCdf x' =+      let (left, right) = splitByMean x' cs+       in case (FT.viewr left, FT.viewl right) of+            (EmptyR, rc :< _) ->+              cdfAtFirst rc x'+            (_, EmptyL) ->+              case FT.viewr left of+                lRest :> lc ->+                  cdfAtLast lc (mWeight (FT.measure lRest)) x'+                EmptyR -> 1.0+            (lRest :> lc, rc :< _) ->+              let lcCum = mWeight (FT.measure lRest)+                  lcIdx = mCount (FT.measure lRest)+                  rcIdx = mCount (FT.measure left)+               in if x' <= cMean lc+                    then+                      if lcIdx == 0+                        then cdfAtFirst lc x'+                        else case FT.viewr lRest of+                          llRest :> llc ->+                            cdfBetween llc (mWeight (FT.measure llRest)) lc lcCum x'+                          EmptyR -> cdfAtFirst lc x'+                    else+                      if rcIdx == numCentroids - 1 && x' > cMean rc+                        then cdfAtLast rc (mWeight (FT.measure left)) x'+                        else cdfBetween lc lcCum rc (mWeight (FT.measure left)) x'++    cdfAtFirst :: Centroid -> Double -> Double+    cdfAtFirst c x'+      | x' < cMean c =+          let innerW = cWeight c / 2+              frac =+                if cMean c == mn+                  then 1.0+                  else (x' - mn) / (cMean c - mn)+           in (innerW * frac) / n+      | otherwise = (cWeight c / 2) / n++    cdfAtLast :: Centroid -> Double -> Double -> Double+    cdfAtLast c cumBefore x'+      | x' > cMean c =+          let halfW = cWeight c / 2+              rightW = n - cumBefore - halfW+              frac =+                if mx == cMean c+                  then 0.0+                  else (x' - cMean c) / (mx - cMean c)+           in (cumBefore + halfW + rightW * frac) / n+      | otherwise = (cumBefore + cWeight c / 2) / n++    cdfBetween :: Centroid -> Double -> Centroid -> Double -> Double -> Double+    cdfBetween lc lcCum rc rcCum x'+      | x' <= cMean lc = (lcCum + cWeight lc / 2) / n+      | x' >= cMean rc = (rcCum + cWeight rc / 2) / n+      | otherwise =+          let lMid = lcCum + cWeight lc / 2+              rMid = rcCum + cWeight rc / 2+              frac =+                if cMean lc == cMean rc+                  then 0.5+                  else (x' - cMean lc) / (cMean rc - cMean lc)+           in (lMid + frac * (rMid - lMid)) / n++-- ---------------------------------------------------------------------------+-- Merge+-- ---------------------------------------------------------------------------++-- | Merge two t-digests into one, preserving accuracy.+--+-- The merge operation inserts every centroid of the second digest into the+-- first (using 'addWeighted' with the centroid's mean and weight), then+-- applies 'compress' to restore the \(K_1\) size invariant.+--+-- This is the standard approach for combining digests computed on+-- disjoint data partitions, enabling distributed and parallel quantile+-- estimation.  In a MapReduce-style pipeline, each mapper builds a local+-- t'TDigest' and the reducer merges them with 'merge'.  Because 'compress'+-- enforces the same \(O(\delta)\) centroid bound, the merged result has+-- the same space footprint as a single-stream digest.+--+-- See Dunning (2021), Section 4.3 (<https://doi.org/10.1016/j.simpa.2020.100049>)+-- for a discussion of mergeability and its applications.+merge :: TDigest -> TDigest -> TDigest+merge td other =+  let otherCs = ftToList (tdCentroids other)+      combined = foldl' (\d c -> addWeighted (cMean c) (cWeight c) d) td otherCs+   in compress combined++-- ---------------------------------------------------------------------------+-- Queries+-- ---------------------------------------------------------------------------++-- | Return the total weight of all values added to the digest.+--+-- This is \(O(1)\), as the total weight is cached in the t'TDigest' record.+-- For an unweighted stream, this equals the number of observations.+totalWeight :: TDigest -> Double+totalWeight = tdTotalWeight++-- | Return the number of centroids currently stored in the digest.+--+-- This is \(O(1)\) via the @mCount@ component of the finger tree's monoidal+-- measure.  The count is always at most \(3\delta\) (and at most+-- \(\delta + 1\) immediately after 'compress').+centroidCount :: TDigest -> Int+centroidCount = mCount . FT.measure . tdCentroids++-- ---------------------------------------------------------------------------+-- Utility+-- ---------------------------------------------------------------------------++clamp :: Double -> Double -> Double -> Double+clamp lo hi x+  | x < lo = lo+  | x > hi = hi+  | otherwise = x++-- ---------------------------------------------------------------------------+-- Additional accessors (for Mutable interop)+-- ---------------------------------------------------------------------------++-- | Return the list of centroids in sorted order (by mean).+--+-- Useful for serialisation, interoperability with mutable implementations,+-- debugging, and visualisation of the digest's internal distribution.  The+-- list is produced by an in-order traversal of the finger tree in+-- \(O(n)\).+centroidList :: TDigest -> [Centroid]+centroidList = ftToList . tdCentroids++-- | Return the compression parameter \(\delta\).+--+-- This is needed for serialisation and for reconstructing a digest with+-- 'fromComponents'.+getDelta :: TDigest -> Double+getDelta = tdDelta++-- | Return the minimum observed value.+--+-- The global minimum is tracked separately from the centroids because the+-- first centroid's mean may be larger than the minimum (if multiple values+-- have been merged into it).  The minimum is used for boundary interpolation+-- in 'quantile' and 'cdf' at \(q \to 0\) and \(x \to \min\).+getMin :: TDigest -> Double+getMin = tdMin++-- | Return the maximum observed value.+--+-- Symmetric to 'getMin': the global maximum is used for boundary+-- interpolation in 'quantile' and 'cdf' at \(q \to 1\) and \(x \to \max\).+getMax :: TDigest -> Double+getMax = tdMax++-- | Reconstruct a t-digest from its serialised components: a list of+-- centroids (which /must/ be in non-decreasing order of mean), the total+-- weight, the global minimum and maximum, and the compression parameter+-- \(\delta\).+--+-- This function trusts the caller to provide correctly sorted centroids and+-- consistent metadata.  It is intended for deserialisation and for+-- transferring digests between this pure implementation and the mutable+-- array-backed implementations in other languages.  No validation or+-- re-compression is performed.+--+-- __Usage example:__+--+-- @+-- let cs = 'centroidList' td+--     tw = 'totalWeight' td+--     mn = 'getMin' td+--     mx = 'getMax' td+--     d  = 'getDelta' td+--     td' = 'fromComponents' cs tw mn mx d+-- -- td' is equivalent to td+-- @+fromComponents :: [Centroid] -> Double -> Double -> Double -> Double -> TDigest+fromComponents cs tw mn mx delta =+  TDigest+    { tdCentroids = FT.fromList cs,+      tdTotalWeight = tw,+      tdMin = mn,+      tdMax = mx,+      tdDelta = delta,+      tdMaxCentroids = ceiling (delta * 3)+    }
+ src/Data/Sketch/TDigest/Mutable.hs view
@@ -0,0 +1,1022 @@+-- |+-- Module      : Data.Sketch.TDigest.Mutable+-- Description : Mutable t-digest via buffer-and-flush with greedy merge in the ST monad+-- Copyright   : (c) Nadia Yvette Chambers, 2025+-- License     : BSD-3-Clause+-- Maintainer  : nadia.yvette.chambers@gmail.com+-- Stability   : experimental+--+-- A mutable t-digest implementation backed by mutable vectors from the+-- @vector@ package, operating entirely within the 'Control.Monad.ST.ST'+-- monad.  Centroids are stored in a mutable unboxed-style vector of+-- @(mean, weight)@ pairs kept sorted by mean.  Prefix sums of weights+-- are maintained for \(O(\log n)\) quantile and CDF queries via binary+-- search.+--+-- == Background+--+-- The /t-digest/ is a streaming, mergeable sketch for approximate quantile+-- estimation, introduced by Dunning (2021)+-- (<https://doi.org/10.1016/j.simpa.2020.100049>).  It belongs to the+-- family of quantile summaries that trade bounded space for approximate+-- answers, a line of work originating with Munro & Paterson (1980)+-- (<https://doi.org/10.1016/0304-3975(80)90061-4>) and continued by+-- Greenwald & Khanna (2001)+-- (<https://doi.org/10.1145/375663.375670>).  The key innovation of the+-- t-digest is the use of a /scale function/ to allow larger centroids in+-- the interior of the distribution while keeping centroids near the tails+-- small, yielding high relative accuracy at extreme quantiles (e.g.,+-- \(q = 0.99\) or \(q = 0.001\)).+--+-- This module provides the /mutable/ variant, which follows a+-- /buffer-and-flush/ strategy: incoming data points are appended to an+-- unsorted buffer in amortised \(O(1)\) time; when the buffer reaches+-- capacity, the entire buffer is flushed into the sorted centroid array+-- via insertion sort followed by a single-pass greedy merge.  This+-- amortised design is the approach recommended by Dunning & Ertl (2019)+-- (<https://arxiv.org/abs/1902.04023>) for high-throughput ingestion.+--+-- == The ST monad approach+--+-- This module uses 'Control.Monad.ST.ST' rather than 'IO' for in-place+-- mutation.  The 'ST' monad provides:+--+-- * /True in-place mutation/ — centroid vectors, prefix-sum arrays, and+--   the pending-addition buffer are modified destructively, avoiding the+--   allocation overhead of persistent data structures.+-- * /Rank-2 type safety/ — the universally quantified state token @s@ in+--   'runTDigest' (equivalently 'Control.Monad.ST.runST') guarantees that+--   no mutable reference can escape the computation.  This is enforced+--   statically by the type system, with no runtime cost.+-- * /No IO escape/ — unlike @IORef@ or @IOVector@, 'STRef' and+--   'Data.Vector.Mutable.MVector' in 'ST' cannot perform arbitrary+--   side-effects.  The result of 'runTDigest' is a pure value.+--+-- For a purely functional alternative that avoids mutable state entirely,+-- see "Data.Sketch.TDigest", which stores centroids in a finger tree+-- (Hinze & Paterson, 2006;+-- <https://doi.org/10.1017/S0956796805005769>) with a four-component+-- monoidal measure, providing \(O(\log n)\) insertion without buffering+-- and \(O(\delta \log n)\) compression via split-based merge.+--+-- == Space bounds+--+-- The t-digest maintains at most \(O(\delta)\) centroids after each+-- compression pass, where \(\delta\) is the compression parameter+-- (default 100).  Between compressions the buffer may hold up to+-- \(5\delta\) pending additions, so peak memory usage is bounded by+-- \(O(\delta)\) centroid slots plus \(O(\delta)\) buffer slots, for a+-- total working set of \(O(\delta)\).  The initial centroid vector is+-- allocated with capacity \(10\delta\) to accommodate the merge of the+-- buffer contents with the existing centroids without reallocation in+-- steady state.+--+-- Because \(\delta\) is a user-chosen constant (typically 100–300), space+-- usage is /independent of the number of data points/ ingested —+-- precisely the guarantee required for streaming applications.+--+-- == Algorithm+--+-- The core algorithm is /buffer-and-flush with greedy merge/:+--+-- 1. __Buffer phase.__  Each call to 'addWeighted' appends the+--    @(mean, weight)@ pair to the end of the buffer in \(O(1)\)+--    amortised time (the buffer is doubled if it overflows).  When the+--    buffer length reaches the capacity \(5\delta\), 'compress' is+--    triggered automatically.+--+-- 2. __Sort phase.__  On compress, all existing centroids and buffered+--    points are collected into a single temporary array and sorted by+--    mean using insertion sort.  Insertion sort is chosen because the+--    existing centroids are already sorted, so the merge of two sorted+--    runs is nearly linear; in practice the buffer is small relative to+--    the total.+--+-- 3. __Greedy merge phase.__  The sorted array is traversed left to+--    right.  A running centroid accumulates incoming points as long as+--    the K1 scale function constraint is satisfied:+--+--    \[+--      k_1(q, \delta) = \frac{\delta}{2\pi} \arcsin(2q - 1)+--    \]+--+--    Two adjacent quantile positions \(q_0\) and \(q_1\) may share a+--    centroid if and only if \(k_1(q_1) - k_1(q_0) \le 1\).  When+--    the constraint would be violated, the accumulated centroid is+--    emitted and a new accumulation begins.  The merged centroid's mean+--    is the standard weighted mean:+--+--    \[+--      \mu_{\text{new}} = \frac{\mu_a \, w_a + \mu_b \, w_b}{w_a + w_b}+--    \]+--+-- 4. __Prefix-sum rebuild.__  After merging, the prefix-sum array is+--    rebuilt in a single linear pass so that @prefixSum[i]@ equals the+--    cumulative weight of centroids \(0, 1, \ldots, i{-}1\).  This+--    array enables \(O(\log n)\) quantile and CDF queries via binary+--    search.+--+-- == Companion implementations+--+-- This project contains 28 language implementations of the merging+-- t-digest.  While this Haskell module uses flat mutable vectors for+-- simplicity, 22 of the other mutable implementations store centroids+-- in /array-backed 2-3-4 trees/.  The 2-3-4 tree is a B-tree of order 4+-- (Bayer & McCreight, 1972; <https://doi.org/10.1007/BF00288683>),+-- equivalent via the well-known isomorphism to a red-black tree (Guibas+-- & Sedgewick, 1978;+-- <https://doi.org/10.1109/SFCS.1978.3>; see also Sedgewick, 2008;+-- <https://sedgewick.io/wp-content/themes/flavor/papers/2008LLRB.pdf>+-- for the left-leaning specialisation).+--+-- The 2-3-4 tree representation offers several advantages for+-- fine-grained quantile workloads:+--+-- * /Cache locality/ — storing nodes in a contiguous array rather than+--   heap-allocated pointers improves spatial locality and reduces cache+--   misses, which matters when the centroid count \(\delta\) is in the+--   hundreds.+-- * /Worst-case \(O(\log n)\) insertion and deletion/ — unlike the+--   amortised buffer-and-flush approach here, the tree-based variants+--   can absorb each data point immediately with a guaranteed logarithmic+--   bound, which is useful in latency-sensitive contexts.+-- * /Robustness for fine-grained queries/ — maintaining a balanced tree+--   of centroids at all times (rather than deferring organisation to+--   periodic compressions) ensures that quantile and CDF queries always+--   see a fully up-to-date structure.+--+-- == Quick start+--+-- @+-- import Data.Sketch.TDigest.Mutable+-- import Control.Monad (forM_)+--+-- example :: Maybe Double+-- example = 'runTDigest' $ do+--   td <- 'new'+--   forM_ [1.0 .. 10000.0] $ \\v -> 'add' v td+--   'quantile' 0.99 td+-- @+module Data.Sketch.TDigest.Mutable+  ( -- * Type+    MDigest,++    -- * Construction+    new,+    newWith,++    -- * Insertion+    add,+    addWeighted,++    -- * Compression+    compress,++    -- * Queries+    quantile,+    cdf,++    -- * Merging+    merge,++    -- * Conversion+    freeze,+    thaw,++    -- * Accessors+    totalWeight,+    centroidCount,++    -- * Runner+    runTDigest,+  )+where++import Control.Monad (when)+import Control.Monad.ST (ST, runST)+import Data.STRef+  ( STRef,+    modifySTRef',+    newSTRef,+    readSTRef,+    writeSTRef,+  )+import qualified Data.Sketch.TDigest as TD+import qualified Data.Vector.Mutable as MV++-- ---------------------------------------------------------------------------+-- Type+-- ---------------------------------------------------------------------------++-- | A truly mutable t-digest operating within the 'ST' monad, using+-- mutable vectors for centroids, prefix sums, and a pending-additions+-- buffer.+--+-- The internal state comprises:+--+-- * __Centroid vector__ (@mdCentroids@) — a mutable vector of+--   @(mean, weight)@ pairs maintained in sorted order by mean.  After+--   each call to 'compress', this vector contains at most \(O(\delta)\)+--   entries.+--+-- * __Prefix-sum vector__ (@mdPrefixSums@) — a mutable vector of length+--   \(n_c + 1\) (where \(n_c\) is the centroid count) satisfying+--   @prefixSum[0] = 0@ and @prefixSum[i] = \sum_{j=0}^{i-1} w_j@.+--   This enables \(O(\log n_c)\) quantile and CDF queries via binary+--   search without a linear scan.+--+-- * __Buffer__ (@mdBuffer@) — an unsorted staging area for incoming+--   data points.  Points are appended in \(O(1)\) amortised time.+--   When the buffer length reaches the capacity \(5\delta\), a+--   compress cycle is triggered automatically, flushing the buffer+--   into the centroid vector.+--+-- * __Scalar accumulators__ — @mdTotalWeight@, @mdMin@, and @mdMax@+--   track the running total weight and extrema across all points ever+--   ingested (including buffered ones not yet compressed).+--+-- __Invariants.__  Between calls to exported functions:+--+-- 1. The centroid vector is sorted by mean.+-- 2. The prefix-sum vector is consistent with the centroid vector.+-- 3. The buffer length is in \([0, 5\delta)\).+-- 4. @totalWeight@ equals the sum of all centroid weights plus all+--    buffered point weights.+--+-- Invariants (1) and (2) may be temporarily violated while the buffer+-- is non-empty; they are restored by 'compress'.+data MDigest s = MDigest+  { -- | Mutable vector of (mean, weight) pairs, sorted by mean.+    mdCentroids :: !(STRef s (MV.MVector s (Double, Double))),+    -- | Prefix sums: prefixSum[0] = 0, prefixSum[i] = sum of weights 0..i-1.+    mdPrefixSums :: !(STRef s (MV.MVector s Double)),+    -- | Buffer for pending additions.+    mdBuffer :: !(STRef s (MV.MVector s (Double, Double))),+    mdTotalWeight :: !(STRef s Double),+    mdMin :: !(STRef s Double),+    mdMax :: !(STRef s Double),+    mdBufferLen :: !(STRef s Int),+    mdCentroidCount :: !(STRef s Int),+    mdDelta :: !(STRef s Double),+    mdBufferCap :: !(STRef s Int)+  }++-- ---------------------------------------------------------------------------+-- Construction+-- ---------------------------------------------------------------------------++-- | Create a new, empty mutable t-digest with the default compression+-- parameter \(\delta = 100\).+--+-- This is equivalent to @'newWith' 100@.  A \(\delta\) of 100 yields+-- roughly 100 centroids after compression and provides relative accuracy+-- on the order of \(10^{-3}\) at extreme quantiles — sufficient for most+-- monitoring and analytics workloads.  See Dunning & Ertl (2019)+-- (<https://arxiv.org/abs/1902.04023>) for empirical accuracy tables.+new :: ST s (MDigest s)+new = newWith 100++-- | Create a new, empty mutable t-digest with a given compression+-- parameter \(\delta\).+--+-- The compression parameter controls the trade-off between accuracy and+-- space.  Larger values of \(\delta\) produce more centroids (up to+-- \(O(\delta)\)) and therefore higher accuracy, at the cost of increased+-- memory and compression time.  Typical values range from 50 (coarse) to+-- 300 (very accurate).+--+-- __Buffer capacity.__  The internal buffer is sized to hold+-- \(\lceil 5\delta \rceil\) pending additions.  This factor of 5 is an+-- empirical choice: it amortises the cost of compression (which is+-- \(O(\delta)\) per flush) over enough insertions to make the per-insert+-- cost effectively \(O(1)\).+--+-- __Initial centroid allocation.__  The centroid vector is pre-allocated+-- with capacity \(10\delta\) — enough to hold the existing centroids+-- (at most \(\sim\delta\) after the previous compression) plus a full+-- buffer of \(5\delta\) points, without reallocation during the merge+-- phase.+newWith :: Double -> ST s (MDigest s)+newWith delta = do+  let bufCap = ceiling (delta * 5) :: Int+      initCentroidCap = bufCap * 2+  centroids <- MV.new initCentroidCap+  prefix <- MV.new 1+  MV.write prefix 0 0.0+  buf <- MV.new bufCap+  cRef <- newSTRef centroids+  pRef <- newSTRef prefix+  bRef <- newSTRef buf+  twRef <- newSTRef 0.0+  mnRef <- newSTRef (1 / 0)+  mxRef <- newSTRef (-(1 / 0))+  blRef <- newSTRef 0+  ccRef <- newSTRef 0+  dRef <- newSTRef delta+  bcRef <- newSTRef bufCap+  return+    MDigest+      { mdCentroids = cRef,+        mdPrefixSums = pRef,+        mdBuffer = bRef,+        mdTotalWeight = twRef,+        mdMin = mnRef,+        mdMax = mxRef,+        mdBufferLen = blRef,+        mdCentroidCount = ccRef,+        mdDelta = dRef,+        mdBufferCap = bcRef+      }++-- ---------------------------------------------------------------------------+-- Insertion+-- ---------------------------------------------------------------------------++-- | Add a single value with unit weight to the digest.+--+-- @'add' x md = 'addWeighted' x 1 md@+--+-- This is the common case for unweighted data streams.  The value is+-- appended to the internal buffer in \(O(1)\) amortised time;+-- compression is triggered automatically when the buffer is full.+add :: Double -> MDigest s -> ST s ()+add x = addWeighted x 1++-- | Add a value with a given weight to the digest.+--+-- __Complexity.__  Amortised \(O(1)\).  The value is appended to the+-- tail of the unsorted buffer; no sorting or merging occurs at this+-- stage.  The running minimum, maximum, and total weight are updated+-- eagerly so that they are always available without a compress cycle.+--+-- __Auto-compress.__  When the buffer length reaches the buffer capacity+-- \(\lceil 5\delta \rceil\), 'compress' is called automatically.  This+-- ensures that memory usage never exceeds \(O(\delta)\) beyond the+-- allocated capacity.+--+-- __Buffer growth.__  If the buffer's underlying vector is full (which+-- can happen if the buffer capacity has been reached but 'compress' has+-- not yet been triggered by a prior code path), the vector is doubled in+-- size via 'Data.Vector.Mutable.grow'.  In steady-state operation this+-- branch is not taken because auto-compress fires at the capacity+-- threshold.+addWeighted :: Double -> Double -> MDigest s -> ST s ()+addWeighted x w md = do+  -- Update min/max+  mn <- readSTRef (mdMin md)+  when (x < mn) $ writeSTRef (mdMin md) x+  mx <- readSTRef (mdMax md)+  when (x > mx) $ writeSTRef (mdMax md) x+  -- Update total weight+  modifySTRef' (mdTotalWeight md) (+ w)+  -- Append to buffer+  bl <- readSTRef (mdBufferLen md)+  buf <- readSTRef (mdBuffer md)+  let bufLen = MV.length buf+  -- Grow buffer if needed+  buf' <-+    if bl >= bufLen+      then do+        newBuf <- MV.grow buf bufLen+        writeSTRef (mdBuffer md) newBuf+        return newBuf+      else return buf+  MV.write buf' bl (x, w)+  let bl' = bl + 1+  writeSTRef (mdBufferLen md) bl'+  -- Compress if buffer is full+  bc <- readSTRef (mdBufferCap md)+  when (bl' >= bc) $ compress md++-- ---------------------------------------------------------------------------+-- Compression+-- ---------------------------------------------------------------------------++-- | Force compression of the buffer into the centroid list.+--+-- Compression implements the /buffer-and-flush/ strategy described by+-- Dunning & Ertl (2019) (<https://arxiv.org/abs/1902.04023>).  The+-- algorithm proceeds in four stages:+--+-- 1. __Collect.__  All existing centroids and buffered points are copied+--    into a single temporary array of length \(n_c + n_b\).+--+-- 2. __Sort.__  The temporary array is sorted by centroid mean using+--    insertion sort.  Because the first \(n_c\) entries are already in+--    sorted order (they come from the centroid vector), the sort is+--    adaptive: it performs at most \(O(n_b \cdot (n_c + n_b))\)+--    comparisons, which is efficient when \(n_b \ll n_c\).+--+-- 3. __Greedy merge.__  The sorted array is traversed left to right.  A+--    running centroid accumulates successive entries as long as the K1+--    scale function constraint is satisfied.  The K1 scale function is+--    defined as:+--+--    \[+--      k_1(q, \delta) \;=\; \frac{\delta}{2\pi}\,\arcsin(2q - 1)+--    \]+--+--    Given a running accumulated weight \(W_{\text{so far}}\) and a+--    total digest weight \(N\), the quantile interval of the proposed+--    merged centroid spans \([q_0, q_1]\) where+--    \(q_0 = W_{\text{so far}} / N\) and+--    \(q_1 = (W_{\text{so far}} + w_{\text{proposed}}) / N\).+--    The merge is permitted if:+--+--    \[+--      k_1(q_1, \delta) - k_1(q_0, \delta) \;\le\; 1+--    \]+--+--    When this constraint would be violated, the accumulated centroid is+--    emitted and a fresh accumulation begins.  Singletons (weight \(\le 1\))+--    are always merged with their neighbour when not at the boundary, to+--    prevent centroid count blow-up from unit-weight insertions.+--+-- 4. __Rebuild prefix sums.__  A single linear pass rebuilds the+--    prefix-sum array for subsequent \(O(\log n)\) queries.+--+-- __Complexity.__  \(O((n_c + n_b)^2)\) worst-case due to insertion sort,+-- but \(O(n_c + n_b)\) in the common case when the buffer is small+-- relative to the sorted centroid array.  The output centroid count is+-- bounded by \(O(\delta)\).+compress :: MDigest s -> ST s ()+compress md = do+  bl <- readSTRef (mdBufferLen md)+  cc <- readSTRef (mdCentroidCount md)+  when (bl > 0 || cc > 1) $ do+    -- Collect all items: existing centroids + buffer+    let totalItems = cc + bl+    allItems <- MV.new totalItems+    -- Copy centroids+    centroids <- readSTRef (mdCentroids md)+    copyN centroids allItems cc 0 0+    -- Copy buffer+    buf <- readSTRef (mdBuffer md)+    copyN buf allItems bl 0 cc+    -- Sort all items by mean (insertion sort is fine for small arrays)+    insertionSort allItems totalItems+    -- Greedy merge+    delta <- readSTRef (mdDelta md)+    n <- readSTRef (mdTotalWeight md)+    if totalItems == 0+      then do+        writeSTRef (mdCentroidCount md) 0+        writeSTRef (mdBufferLen md) 0+        rebuildPrefixSums md+      else do+        -- Merge in-place into a result vector+        merged <- MV.new totalItems+        (m0, w0) <- MV.read allItems 0+        -- Walk and merge+        newCount <- greedyMergeVec delta n allItems totalItems merged m0 w0+        -- Write back+        writeSTRef (mdCentroids md) merged+        writeSTRef (mdCentroidCount md) newCount+        writeSTRef (mdBufferLen md) 0+        rebuildPrefixSums md++-- Copy n elements from src starting at srcOff to dst starting at dstOff+copyN :: MV.MVector s (Double, Double) -> MV.MVector s (Double, Double) -> Int -> Int -> Int -> ST s ()+copyN src dst n srcOff dstOff = go 0+  where+    go i+      | i >= n = return ()+      | otherwise = do+          v <- MV.read src (srcOff + i)+          MV.write dst (dstOff + i) v+          go (i + 1)++-- Insertion sort by first element of pair+insertionSort :: MV.MVector s (Double, Double) -> Int -> ST s ()+insertionSort vec n = go 1+  where+    go i+      | i >= n = return ()+      | otherwise = do+          val@(key, _) <- MV.read vec i+          j <- findInsertPos vec key (i - 1)+          -- Shift elements right+          shiftRight vec (j + 1) i+          MV.write vec (j + 1) val+          go (i + 1)++    findInsertPos :: MV.MVector s (Double, Double) -> Double -> Int -> ST s Int+    findInsertPos _ _ (-1) = return (-1)+    findInsertPos v key j = do+      (jKey, _) <- MV.read v j+      if jKey > key+        then findInsertPos v key (j - 1)+        else return j++    shiftRight :: MV.MVector s (Double, Double) -> Int -> Int -> ST s ()+    shiftRight v from to+      | from >= to = return ()+      | otherwise = go' (to - 1)+      where+        go' j+          | j < from = return ()+          | otherwise = do+              val <- MV.read v j+              MV.write v (j + 1) val+              go' (j - 1)++-- Greedy merge: walk sorted items, merge adjacent when scale function allows.+-- Returns the number of merged centroids written to 'out'.+greedyMergeVec ::+  Double ->+  Double ->+  MV.MVector s (Double, Double) ->+  Int ->+  MV.MVector s (Double, Double) ->+  Double ->+  Double ->+  ST s Int+greedyMergeVec delta n items totalItems out initMean initWeight = go 1 0 initMean initWeight 0+  where+    k q = (delta / (2 * pi)) * asin (2 * q - 1)++    go idx weightSoFar curMean curWeight outIdx+      | idx >= totalItems = do+          -- Emit final centroid+          MV.write out outIdx (curMean, curWeight)+          return (outIdx + 1)+      | otherwise = do+          (itemMean, itemWeight) <- MV.read items idx+          let proposed = curWeight + itemWeight+              q0 = weightSoFar / n+              q1 = (weightSoFar + proposed) / n+              canMerge =+                (proposed <= 1 && idx < totalItems - 1)+                  || (k q1 - k q0 <= 1.0)+          if canMerge+            then do+              -- Merge: weighted mean+              let newW = curWeight + itemWeight+                  newM = (curMean * curWeight + itemMean * itemWeight) / newW+              go (idx + 1) weightSoFar newM newW outIdx+            else do+              -- Emit current centroid, start new one+              MV.write out outIdx (curMean, curWeight)+              go (idx + 1) (weightSoFar + curWeight) itemMean itemWeight (outIdx + 1)++-- Rebuild prefix sums from current centroids.+-- prefixSum has (centroidCount + 1) entries:+--   prefixSum[0] = 0+--   prefixSum[i] = sum of weights of centroids 0..i-1+rebuildPrefixSums :: MDigest s -> ST s ()+rebuildPrefixSums md = do+  cc <- readSTRef (mdCentroidCount md)+  prefix <- MV.new (cc + 1)+  MV.write prefix 0 0.0+  centroids <- readSTRef (mdCentroids md)+  buildPS centroids prefix cc 0 0.0+  writeSTRef (mdPrefixSums md) prefix+  where+    buildPS _ _ n i _+      | i >= n = return ()+    buildPS cs ps n i acc = do+      (_, w) <- MV.read cs i+      let acc' = acc + w+      MV.write ps (i + 1) acc'+      buildPS cs ps n (i + 1) acc'++-- ---------------------------------------------------------------------------+-- Queries+-- ---------------------------------------------------------------------------++-- | Estimate the value at quantile \(q\) where \(0 \le q \le 1\).+--+-- Returns 'Nothing' if the digest is empty (no data points have been+-- added).+--+-- __Algorithm.__  The digest is first compressed (flushing any buffered+-- points) to ensure the centroid vector and prefix sums are up to date.+-- A binary search on the prefix-sum array locates the centroid \(c_i\)+-- whose cumulative weight interval contains the target rank+-- \(t = q \cdot N\).  The returned value is then computed by linear+-- interpolation between adjacent centroid midpoints:+--+-- * For the /leftmost/ centroid (\(i = 0\)), the target rank+--   \(t < w_0 / 2\) triggers interpolation between the observed minimum+--   and \(\mu_0\).+-- * For the /rightmost/ centroid (\(i = n_c - 1\)), the target rank+--   \(t > N - w_{n_c - 1} / 2\) triggers interpolation between+--   \(\mu_{n_c - 1}\) and the observed maximum.+-- * For /interior/ centroids, the result is linearly interpolated+--   between the midpoints of \(c_i\) and \(c_{i+1}\):+--+--   \[+--     \hat{x} = \mu_i + \frac{t - m_i}{m_{i+1} - m_i} \cdot (\mu_{i+1} - \mu_i)+--   \]+--+--   where \(m_i = \text{cumBefore}_i + w_i / 2\) is the midpoint rank+--   of centroid \(i\).+--+-- __Complexity.__  \(O(\delta)\) due to the initial compress (if the+-- buffer is non-empty), then \(O(\log \delta)\) for the binary search.+-- If the buffer is already empty, the cost is \(O(\log \delta)\).+quantile :: Double -> MDigest s -> ST s (Maybe Double)+quantile q md = do+  compress md+  cc <- readSTRef (mdCentroidCount md)+  if cc == 0+    then return Nothing+    else+      if cc == 1+        then do+          centroids <- readSTRef (mdCentroids md)+          (m, _) <- MV.read centroids 0+          return (Just m)+        else do+          n <- readSTRef (mdTotalWeight md)+          mn <- readSTRef (mdMin md)+          mx <- readSTRef (mdMax md)+          let q' = clamp 0 1 q+              target = q' * n+          centroids <- readSTRef (mdCentroids md)+          prefix <- readSTRef (mdPrefixSums md)+          -- Binary search: find largest i such that prefixSum[i] <= target+          -- i is in [0, cc], and represents the centroid index boundary+          i <- bsearchPrefix prefix (cc + 1) target+          -- i is the index into prefix sums; the centroid index is (i - 1)+          -- but we need to handle boundary cases+          let ci = max 0 (min (cc - 1) (i - 1))+          -- Now interpolate+          (cMean, cWeight) <- MV.read centroids ci+          cumBefore <- MV.read prefix ci+          let mid = cumBefore + cWeight / 2.0+          if ci == 0 && target < cWeight / 2.0+            then do+              -- Left boundary: interpolate between min and first centroid+              let result =+                    if cWeight == 1+                      then mn+                      else mn + (cMean - mn) * (target / (cWeight / 2.0))+              return (Just result)+            else+              if ci == cc - 1+                then do+                  -- Right boundary+                  let remaining = n - cWeight / 2.0+                  if target > n - cWeight / 2.0+                    then do+                      let result =+                            if cWeight == 1+                              then mx+                              else cMean + (mx - cMean) * ((target - remaining) / (cWeight / 2.0))+                      return (Just result)+                    else return (Just cMean)+                else do+                  -- Middle: interpolate between adjacent centroid midpoints+                  (nextMean, nextWeight) <- MV.read centroids (ci + 1)+                  cumNext <- MV.read prefix (ci + 1)+                  let nextMid = cumNext + nextWeight / 2.0+                  if target <= nextMid+                    then do+                      let frac =+                            if nextMid == mid+                              then 0.5+                              else (target - mid) / (nextMid - mid)+                      return (Just (cMean + frac * (nextMean - cMean)))+                    else do+                      -- Walk forward from ci+1+                      walkQuantile centroids prefix cc n mn mx target (ci + 1)++-- Walk forward to find the right centroid for the target+walkQuantile ::+  MV.MVector s (Double, Double) ->+  MV.MVector s Double ->+  Int ->+  Double ->+  Double ->+  Double ->+  Double ->+  Int ->+  ST s (Maybe Double)+walkQuantile centroids prefix cc n mn mx target = go+  where+    go i+      | i >= cc = return (Just mx)+      | otherwise = do+          (cMean, cWeight) <- MV.read centroids i+          cumBefore <- MV.read prefix i+          let mid = cumBefore + cWeight / 2.0+          if i == 0 && target < cWeight / 2.0+            then do+              let result =+                    if cWeight == 1+                      then mn+                      else mn + (cMean - mn) * (target / (cWeight / 2.0))+              return (Just result)+            else+              if i == cc - 1+                then do+                  let remaining = n - cWeight / 2.0+                  if target > remaining+                    then do+                      let result =+                            if cWeight == 1+                              then mx+                              else cMean + (mx - cMean) * ((target - remaining) / (cWeight / 2.0))+                      return (Just result)+                    else return (Just cMean)+                else do+                  (nextMean, nextWeight) <- MV.read centroids (i + 1)+                  cumNext <- MV.read prefix (i + 1)+                  let nextMid = cumNext + nextWeight / 2.0+                  if target <= nextMid+                    then do+                      let frac =+                            if nextMid == mid+                              then 0.5+                              else (target - mid) / (nextMid - mid)+                      return (Just (cMean + frac * (nextMean - cMean)))+                    else go (i + 1)++-- Binary search on prefix sums: find largest i in [0, len-1] such that+-- prefix[i] <= target.+bsearchPrefix :: MV.MVector s Double -> Int -> Double -> ST s Int+bsearchPrefix prefix len target = go 0 (len - 1)+  where+    go lo hi+      | lo >= hi = return lo+      | otherwise = do+          let mid = (lo + hi + 1) `div` 2+          v <- MV.read prefix mid+          if v <= target+            then go mid hi+            else go lo (mid - 1)++-- | Estimate the cumulative distribution function (CDF) at value \(x\),+-- i.e., the fraction of the distribution that lies at or below \(x\).+--+-- Returns 'Nothing' if the digest is empty.+--+-- __Algorithm.__  Like 'quantile', this function first compresses any+-- buffered points.  It then performs a linear walk over the centroid+-- vector to locate the pair of centroids straddling \(x\), and+-- interpolates:+--+-- * If \(x \le x_{\min}\), the result is 0.+-- * If \(x \ge x_{\max}\), the result is 1.+-- * If \(x\) falls in the half-weight region of the first centroid+--   (i.e., \(x < \mu_0\)), the result is interpolated between 0 and+--   \(w_0 / (2N)\).+-- * If \(x\) falls in the half-weight region of the last centroid,+--   the result is interpolated between+--   \((\sum w - w_{n-1}/2) / N\) and 1.+-- * Otherwise, the result is linearly interpolated between the midpoint+--   ranks of the two bracketing centroids, yielding:+--+--   \[+--     \widehat{F}(x) = \frac{m_i + \frac{x - \mu_i}{\mu_{i+1} - \mu_i} \cdot (m_{i+1} - m_i)}{N}+--   \]+--+-- __Complexity.__  \(O(\delta)\) due to compression plus a linear walk+-- over centroids.+cdf :: Double -> MDigest s -> ST s (Maybe Double)+cdf x md = do+  compress md+  cc <- readSTRef (mdCentroidCount md)+  if cc == 0+    then return Nothing+    else do+      mn <- readSTRef (mdMin md)+      mx <- readSTRef (mdMax md)+      if x <= mn+        then return (Just 0)+        else+          if x >= mx+            then return (Just 1)+            else do+              n <- readSTRef (mdTotalWeight md)+              centroids <- readSTRef (mdCentroids md)+              prefix <- readSTRef (mdPrefixSums md)+              walkCdf centroids prefix cc n mn mx x++walkCdf ::+  MV.MVector s (Double, Double) ->+  MV.MVector s Double ->+  Int ->+  Double ->+  Double ->+  Double ->+  Double ->+  ST s (Maybe Double)+walkCdf centroids prefix cc n mn mx x = go 0+  where+    lastIdx = cc - 1++    go i+      | i >= cc = return (Just 1.0)+      | otherwise = do+          (cMean, cWeight) <- MV.read centroids i+          cumBefore <- MV.read prefix i+          if i == 0 && x < cMean+            then do+              let innerW = cWeight / 2.0+                  frac =+                    if cMean == mn+                      then 1.0+                      else (x - mn) / (cMean - mn)+              return (Just ((innerW * frac) / n))+            else+              if i == 0 && x == cMean+                then return (Just ((cWeight / 2.0) / n))+                else+                  if i == lastIdx && x > cMean+                    then do+                      let halfW = cWeight / 2.0+                          rightW = n - cumBefore - halfW+                          frac =+                            if mx == cMean+                              then 0.0+                              else (x - cMean) / (mx - cMean)+                      return (Just ((cumBefore + halfW + rightW * frac) / n))+                    else+                      if i == lastIdx+                        then return (Just ((cumBefore + cWeight / 2.0) / n))+                        else do+                          let mid = cumBefore + cWeight / 2.0+                          (nextMean, nextWeight) <- MV.read centroids (i + 1)+                          cumNext <- MV.read prefix (i + 1)+                          let nextMid = cumNext + nextWeight / 2.0+                          if x < nextMean+                            then do+                              let frac =+                                    if cMean == nextMean+                                      then 0.5+                                      else (x - cMean) / (nextMean - cMean)+                              return (Just ((mid + frac * (nextMid - mid)) / n))+                            else go (i + 1)++-- ---------------------------------------------------------------------------+-- Accessors+-- ---------------------------------------------------------------------------++-- | Return the total weight of all values added to the digest.+--+-- This includes both compressed centroids and pending buffer entries.+-- The value is maintained eagerly (updated on every 'addWeighted' call),+-- so this accessor is \(O(1)\) and does not trigger compression.+totalWeight :: MDigest s -> ST s Double+totalWeight md = readSTRef (mdTotalWeight md)++-- | Return the number of centroids, compressing any pending buffer first.+--+-- Because the true centroid count is only well-defined after all buffered+-- points have been merged, this function calls 'compress' before reading+-- the count.  If no buffer entries are pending, the compress is a no-op+-- (the guard @bl > 0 || cc > 1@ shortcuts immediately).+--+-- __Complexity.__  \(O(\delta)\) if compression is needed, \(O(1)\)+-- otherwise.+centroidCount :: MDigest s -> ST s Int+centroidCount md = do+  compress md+  readSTRef (mdCentroidCount md)++-- ---------------------------------------------------------------------------+-- Merge+-- ---------------------------------------------------------------------------++-- | Merge a pure 'TD.TDigest' into the mutable digest.+--+-- The pure digest is first compressed, then its centroids are extracted+-- as a list and fed one by one into 'addWeighted'.  This triggers the+-- standard buffer-and-flush lifecycle: centroids accumulate in the+-- buffer and are flushed when the buffer fills.+--+-- This operation is useful in /parallel and distributed/ settings: each+-- worker thread can build a local pure 'TD.TDigest' (or a local+-- t'MDigest' frozen via 'freeze'), and a coordinator can merge all+-- partial digests into a single mutable accumulator.  Because the+-- t-digest is a mergeable sketch (Dunning, 2021;+-- <https://doi.org/10.1016/j.simpa.2020.100049>), the merged result has+-- accuracy comparable to a single-pass digest over the combined data.+--+-- __Complexity.__  \(O(m)\) insertions where \(m\) is the centroid count+-- of the source digest, plus any triggered compressions.+merge :: TD.TDigest -> MDigest s -> ST s ()+merge other md = do+  let otherCompressed = TD.compress other+      otherCs = TD.centroidList otherCompressed+  mapM_ (\c -> addWeighted (TD.cMean c) (TD.cWeight c) md) otherCs++-- ---------------------------------------------------------------------------+-- Freeze / Thaw+-- ---------------------------------------------------------------------------++-- | Snapshot the mutable digest into a pure 'TD.TDigest'.+--+-- The mutable digest is compressed first (flushing any buffered points),+-- then its centroids, total weight, extrema, and compression parameter+-- are read out and packaged into a pure 'TD.TDigest' via+-- 'TD.fromComponents'.+--+-- The resulting pure digest is backed by a finger tree (Hinze &+-- Paterson, 2006; <https://doi.org/10.1017/S0956796805005769>) and+-- supports \(O(\log n)\) queries and further pure insertions.+--+-- __Use case.__  'freeze' is the primary exit path from a mutable+-- computation when the result must be returned to pure code or+-- serialised.  It is also the mechanism for snapshotting a running+-- digest — the mutable digest remains usable after 'freeze'.+--+-- __Complexity.__  \(O(\delta)\) for the compress plus a linear+-- traversal to extract centroids.+freeze :: MDigest s -> ST s TD.TDigest+freeze md = do+  compress md+  cc <- readSTRef (mdCentroidCount md)+  centroids <- readSTRef (mdCentroids md)+  cs <- readCentroids centroids cc 0 []+  tw <- readSTRef (mdTotalWeight md)+  mn <- readSTRef (mdMin md)+  mx <- readSTRef (mdMax md)+  delta <- readSTRef (mdDelta md)+  return (TD.fromComponents cs tw mn mx delta)+  where+    readCentroids _ 0 _ acc = return (reverse acc)+    readCentroids v n i acc = do+      (m, w) <- MV.read v i+      readCentroids v (n - 1) (i + 1) (TD.Centroid m w : acc)++-- | Create a mutable digest from a pure 'TD.TDigest'.+--+-- The pure digest is compressed, its centroids are written into a fresh+-- mutable vector, and the scalar accumulators (total weight, min, max,+-- delta) are initialised from the pure digest's fields.  Prefix sums+-- are rebuilt immediately.+--+-- __Use case.__  'thaw' is the entry path for converting a pure digest+-- (e.g., received from another thread or deserialised from storage) into+-- a mutable digest for continued high-throughput ingestion.  In a+-- parallel/distributed pipeline, each worker can 'thaw' a shared seed+-- digest, ingest a partition of the data mutably, 'freeze' the result,+-- and return it for merging.+--+-- __Complexity.__  \(O(\delta)\) for the copy and prefix-sum rebuild.+thaw :: TD.TDigest -> ST s (MDigest s)+thaw td = do+  let td' = TD.compress td+      cs = TD.centroidList td'+      delta = TD.getDelta td'+  md <- newWith delta+  writeSTRef (mdTotalWeight md) (TD.totalWeight td')+  writeSTRef (mdMin md) (TD.getMin td')+  writeSTRef (mdMax md) (TD.getMax td')+  let n = length cs+  writeSTRef (mdCentroidCount md) n+  centroids <- MV.new (max n 1)+  writeCentroids centroids cs 0+  writeSTRef (mdCentroids md) centroids+  rebuildPrefixSums md+  return md+  where+    writeCentroids _ [] _ = return ()+    writeCentroids v (c : rest) i = do+      MV.write v i (TD.cMean c, TD.cWeight c)+      writeCentroids v rest (i + 1)++-- ---------------------------------------------------------------------------+-- Convenience runner+-- ---------------------------------------------------------------------------++-- | Run an 'ST' computation that uses a mutable t-digest and return the+-- pure result.+--+-- This is a thin wrapper around 'Control.Monad.ST.runST'.  The rank-2+-- type @(forall s. 'ST' s a) -> a@ ensures that no mutable reference+-- (including the t'MDigest' itself, its internal 'STRef's, and its+-- 'Data.Vector.Mutable.MVector's) can escape the scope of the+-- computation.  This guarantee is enforced statically by the Haskell+-- type checker via the universally quantified state token @s@ — any+-- attempt to return or store a value whose type mentions @s@ is a type+-- error.  See Launchbury & Peyton Jones (1994), /Lazy Functional State+-- Threads/, for the theoretical foundation.+--+-- __Usage pattern.__  Typically, one creates a digest with 'new' or+-- 'newWith', performs insertions with 'add' or 'addWeighted', and+-- extracts a result with 'quantile', 'cdf', or 'freeze' — all within+-- the 'runTDigest' block:+--+-- @+-- result :: Maybe Double+-- result = 'runTDigest' $ do+--   td <- 'new'+--   'add' 42.0 td+--   'quantile' 0.5 td+-- @+runTDigest :: (forall s. ST s a) -> a+runTDigest = runST++-- ---------------------------------------------------------------------------+-- Utility+-- ---------------------------------------------------------------------------++clamp :: Double -> Double -> Double -> Double+clamp lo hi x+  | x < lo = lo+  | x > hi = hi+  | otherwise = x