Repository: opensearch-project/opensearch-py-ml (language: python)

Issue:
merging Feature/mcorr to main
### Description
[merging Feature/mcorr to main]
 
### Issues Resolved
[List any issues this PR will resolve]
 
### Check List
- [ ] New functionality includes testing.
  - [ ] All tests pass
- [ ] New functionality has been documented.
  - [ ] New functionality has javadoc added
- [ ] Commits are signed per the DCO using --signoff 

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check [here](https://github.com/opensearch-project/OpenSearch/blob/main/CONTRIBUTING.md#developer-certificate-of-origin).



Discussion / hints:
## [Codecov](https://codecov.io/gh/opensearch-project/opensearch-py-ml/pull/141?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
> Merging [#141](https://codecov.io/gh/opensearch-project/opensearch-py-ml/pull/141?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (0fac70a) into [main](https://codecov.io/gh/opensearch-project/opensearch-py-ml/commit/7ff511327693a2978b8b234212d9ad7ed1414bfb?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (7ff5113) will **increase** coverage by `0.98%`.
> The diff coverage is `97.97%`.

:mega: This organization is not using Codecov’s [GitHub App Integration](https://github.com/apps/codecov). We recommend you install it so Codecov can continue to function properly for your repositories. [Learn more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)

```diff
@@            Coverage Diff             @@
##             main     #141      +/-   ##
==========================================
+ Coverage   89.97%   90.96%   +0.98%     
==========================================
  Files          29       36       +7     
  Lines        3491     3983     +492     
==========================================
+ Hits         3141     3623     +482     
- Misses        350      360      +10     
```


| [Impacted Files](https://codecov.io/gh/opensearch-project/opensearch-py-ml/pull/141?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [...\_ml/ml\_models/metrics\_correlation/wavelet\_tools.py](https://codecov.io/gh/opensearch-project/opensearch-py-ml/pull/141?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-b3BlbnNlYXJjaF9weV9tbC9tbF9tb2RlbHMvbWV0cmljc19jb3JyZWxhdGlvbi93YXZlbGV0X3Rvb2xzLnB5) | `93.18% <93.18%> (ø)` | |
| [...earch\_py\_ml/ml\_models/metrics\_correlation/mcorr.py](https://codecov.io/gh/opensearch-project/opensearch-py-ml/pull/141?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-b3BlbnNlYXJjaF9weV9tbC9tbF9tb2RlbHMvbWV0cmljc19jb3JyZWxhdGlvbi9tY29yci5weQ==) | `97.05% <97.05%> (ø)` | |
| [...nsearch\_py\_ml/ml\_models/metrics\_correlation/dip.py](https://codecov.io/gh/opensearch-project/opensearch-py-ml/pull/141?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-b3BlbnNlYXJjaF9weV9tbC9tbF9tb2RlbHMvbWV0cmljc19jb3JyZWxhdGlvbi9kaXAucHk=) | `97.31% <97.31%> (ø)` | |
| [...l/ml\_models/metrics\_correlation/event\_detection.py](https://codecov.io/gh/opensearch-project/opensearch-py-ml/pull/141?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-b3BlbnNlYXJjaF9weV9tbC9tbF9tb2RlbHMvbWV0cmljc19jb3JyZWxhdGlvbi9ldmVudF9kZXRlY3Rpb24ucHk=) | `98.87% <98.87%> (ø)` | |
| [...els/metrics\_correlation/wavelet\_piecewise\_const.py](https://codecov.io/gh/opensearch-project/opensearch-py-ml/pull/141?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-b3BlbnNlYXJjaF9weV9tbC9tbF9tb2RlbHMvbWV0cmljc19jb3JyZWxhdGlvbi93YXZlbGV0X3BpZWNld2lzZV9jb25zdC5weQ==) | `99.09% <99.09%> (ø)` | |
| [opensearch\_py\_ml/ml\_models/\_\_init\_\_.py](https://codecov.io/gh/opensearch-project/opensearch-py-ml/pull/141?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-b3BlbnNlYXJjaF9weV9tbC9tbF9tb2RlbHMvX19pbml0X18ucHk=) | `100.00% <100.00%> (ø)` | |
| [...nsearch\_py\_ml/ml\_models/metrics\_correlation/nmf.py](https://codecov.io/gh/opensearch-project/opensearch-py-ml/pull/141?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-b3BlbnNlYXJjaF9weV9tbC9tbF9tb2RlbHMvbWV0cmljc19jb3JyZWxhdGlvbi9ubWYucHk=) | `100.00% <100.00%> (ø)` | |
| [...earch\_py\_ml/ml\_models/metrics\_correlation/utils.py](https://codecov.io/gh/opensearch-project/opensearch-py-ml/pull/141?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-b3BlbnNlYXJjaF9weV9tbC9tbF9tb2RlbHMvbWV0cmljc19jb3JyZWxhdGlvbi91dGlscy5weQ==) | `100.00% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)


Fix patch:
diff --git a/opensearch_py_ml/ml_models/__init__.py b/opensearch_py_ml/ml_models/__init__.py
index e27e83605..3ec96ebd5 100644
--- a/opensearch_py_ml/ml_models/__init__.py
+++ b/opensearch_py_ml/ml_models/__init__.py
@@ -5,6 +5,7 @@
 # Any modifications Copyright OpenSearch Contributors. See
 # GitHub history for details.
 
+from .metrics_correlation.mcorr import MCorr
 from .sentencetransformermodel import SentenceTransformerModel
 
-__all__ = ["SentenceTransformerModel"]
+__all__ = ["SentenceTransformerModel", "MCorr"]
diff --git a/opensearch_py_ml/ml_models/metrics_correlation/__init__.py b/opensearch_py_ml/ml_models/metrics_correlation/__init__.py
new file mode 100644
index 000000000..8d89f2583
--- /dev/null
+++ b/opensearch_py_ml/ml_models/metrics_correlation/__init__.py
@@ -0,0 +1,6 @@
+# SPDX-License-Identifier: Apache-2.0
+# The OpenSearch Contributors require contributions made to
+# this file be licensed under the Apache-2.0 license or a
+# compatible open source license.
+# Any modifications Copyright OpenSearch Contributors. See
+# GitHub history for details.
diff --git a/opensearch_py_ml/ml_models/metrics_correlation/dip.py b/opensearch_py_ml/ml_models/metrics_correlation/dip.py
new file mode 100644
index 000000000..b454d731b
--- /dev/null
+++ b/opensearch_py_ml/ml_models/metrics_correlation/dip.py
@@ -0,0 +1,424 @@
+# SPDX-License-Identifier: Apache-2.0
+# The OpenSearch Contributors require contributions made to
+# this file be licensed under the Apache-2.0 license or a
+# compatible open source license.
+# Any modifications Copyright OpenSearch Contributors. See
+# GitHub history for details.
+
+from typing import Tuple
+
+import torch
+
+
+def dip(seq: torch.Tensor) -> float:
+    """
+    Computes the Dip test statistic from a sequence of real-valued
+    observations.
+
+    :param seq: 1-D sequence to be evaluated for unimodality.
+    :type seq: torch.Tensor
+    :return: The dip statistic $d$.
+    :rtype: float
+    """
+
+    seq = torch.sort(seq).values  # ensure data is sorted
+
+    # constant is technically unimodal, but we want to reject these events
+    if seq[0] == seq[-1]:
+        return 0.0
+
+    n = len(seq)
+    low = 0
+    high = n - 1
+
+    # setup: find indices mn of candidate points for
+    # GCM (greatest convex minorant) fit
+    mn = torch.zeros(n)
+    for j in range(1, n):
+        mn[j] = j - 1
+        while True:
+            mnj = int(mn[j].item())
+            mnmnj = int(mn[mnj].item())
+            a = float(mnj - mnmnj)
+            b = float(j - mnj)
+            if mnj == 0 or (seq[j] - seq[mnj]) * a < (seq[mnj] - seq[mnmnj]) * b:
+                break
+            mn[j] = mnmnj
+
+    # setup: find indices mj of candidate points for
+    # LCM (least concave minorant) fit
+    mj = torch.zeros(n)
+    mj[n - 1] = n - 1
+    for k in range(n - 2, -1, -1):
+        mj[k] = k + 1
+        while True:
+            mjk = int(mj[k].item())
+            mjmjk = int(mj[mjk].item())
+            a = float(mjk - mjmjk)
+            b = float(k - mjk)
+            if mjk == n - 1 or (seq[k] - seq[mjk]) * a < (seq[mjk] - seq[mjmjk]) * b:
+                break
+            mj[k] = mjmjk
+
+    #
+    # Main loop: step through candidate endpoints,
+    # finding maximum dip from both GCM and LCM.
+    # If this increases current estimate of dip, continue.
+    # Otherwise break and return dip statistic.
+    #
+    dstar = 1.0
+    while True:
+        # collect GCM change points
+        gcm = [high]
+        i = 0
+        while gcm[i] > low:
+            gcm.append(int(mn[gcm[i]]))
+            i += 1
+        ig = i
+        l_gcm = i
+        ix = ig - 1
+
+        gcm.append(0)
+
+        # collect LCM change points
+        lcm = [low]
+        i = 0
+        while lcm[i] < high:
+            lcm.append(int(mj[lcm[i]]))
+            i += 1
+        ih = i
+        l_lcm = i
+        iv = 1
+
+        lcm.append(0)
+
+        # compute dx
+        d = 0.0
+        if l_lcm != 2 or l_gcm != 2:
+            while True:
+                gcmix = gcm[ix]
+                lcmiv = lcm[iv]
+
+                if gcmix > lcmiv:
+                    gcmi1 = gcm[ix + 1]
+                    dx = (lcmiv - gcmi1 + 1) - (seq[lcmiv] - seq[gcmi1]) * (
+                        gcmix - gcmi1
+                    ) / (seq[gcmix] - seq[gcmi1])
+                    iv += 1
+                    if dx >= d:
+                        d = dx
+                        ig = ix + 1
+                        ih = iv - 1
+                else:
+                    lcmiv1 = lcm[iv - 1]
+                    dx = (seq[gcmix] - seq[lcmiv1]) * (lcmiv - lcmiv1) / (
+                        seq[lcmiv] - seq[lcmiv1]
+                    ) - (gcmix - lcmiv1 - 1)
+                    ix -= 1
+                    if dx >= d:
+                        d = dx
+                        ig = ix + 1
+                        ih = iv
+
+                if ix < 0:
+                    ix = 0
+                if iv > l_lcm:
+                    iv = l_lcm
+
+                if gcm[ix] == lcm[iv]:
+                    break
+        else:
+            d = 1.0
+
+        if d < dstar:
+            break
+
+        # compute dip for cvx minorant
+        dip_l = 0.0
+        for j in range(ig, l_gcm):
+            max_t = 1.0
+            jb = gcm[j + 1]
+            je = gcm[j]
+            if je - jb > 1 and seq[je] != seq[jb]:
+                C = torch.log(torch.tensor(je - jb)) - torch.log(
+                    seq[je] - seq[jb]
+                )  # compute in log space for numeric stability
+                for jjj in range(jb, je + 1):
+                    xx = torch.log(seq[jjj] - seq[jb])
+                    t = (jjj - jb + 1) - torch.exp(xx + C)
+                    if max_t < t:
+                        max_t = t
+
+            if dip_l < max_t:
+                dip_l = max_t
+
+        # compute dip for cnc majorant
+        dip_u = 0.0
+        for j in range(ih, l_lcm):
+            max_t = 1.0
+            jb = lcm[j]
+            je = lcm[j + 1]
+            if je - jb > 1 and seq[je] != seq[jb]:
+                C = torch.log(torch.tensor(je - jb)) - torch.log(
+                    seq[je] - seq[jb]
+                )  # compute in log space for numeric stability
+                for jjj in range(jb, je + 1):
+                    xx = torch.log(seq[jjj] - seq[jb])
+                    t = torch.exp(xx + C) - (jjj - jb - 1)
+                    if max_t < t:
+                        max_t = t
+
+            if dip_u < max_t:
+                dip_u = max_t
+
+        # update current max dip
+        if dip_u > dip_l:
+            dipnew = dip_u
+        else:
+            dipnew = dip_l
+
+        if dstar < dipnew:
+            dstar = dipnew
+
+        if low == gcm[ig] and high == lcm[ih]:
+            break  # no improvement in low / high -> end
+        else:
+            low = gcm[ig]
+            high = lcm[ih]
+
+    # end logic
+    dstar = dstar / (2 * n)
+
+    return dstar
+
+
+def diptest(seq: torch.Tensor) -> Tuple[float, float]:
+    """
+    Implements the dip test, a statistical test to determine whether
+    a 1-D sequence is unimodal.
+
+    The test statistic is defined and analyzed in [1], and an efficient
+    algorithm for its computation is given in [2]. The implementation here
+    closely follows the pseudocode in [2].
+
+    Approximate p-values are obtained by interpolation of a precomputed table.
+    Table entries are the result of bootstrap simulations under the worst-case
+    distribution in the set of null hypotheses, namely the uniform
+    distribution.
+
+    References:
+    [1] Hartigan, JA & Hartigan, PM. The dip test of unimodality. Annals of
+        Statistics (1985).
+    [2] Hartigan, PM. Algorithm AS 217: Computation of the dip statistic for
+        unimodality. JRSSC (1985).
+
+    :param seq: 1-D sequence to be evaluated for unimodality.
+    :type seq: torch.Tensor
+    :return: Dip test statistic $d$ and approximate p-value $p$.
+    :rtype: Tuple[float, float]
+    """
+
+    # torchScript doesn't support tensor values as global variable so we can't
+    # put these constant values in utils.py
+
+    N_VALS: torch.Tensor = torch.tensor(
+        [100, 200, 500, 700, 1000, 2000, 5000, 7000, 10000, 20000, 50000]
+    )
+
+    P_VALS: torch.Tensor = torch.tensor(
+        [0.01, 0.05, 0.1, 0.4, 0.5, 0.75, 0.9, 0.95, 0.99, 0.995]
+    )
+
+    CRIT_VALS: torch.Tensor = torch.tensor(
+        [
+            [
+                0.02274363,
+                0.02554336,
+                0.027407,
+                0.03354036,
+                0.03543797,
+                0.04132709,
+                0.04705229,
+                0.05068449,
+                0.05893375,
+                0.06286501,
+            ],
+            [
+                0.0164563,
+                0.01849594,
+                0.0197459,
+                0.02423924,
+                0.02556999,
+                0.02949423,
+                0.03375041,
+                0.0364896,
+                0.04232255,
+                0.04458542,
+            ],
+            [
+                0.01074585,
+                0.01199022,
+                0.01276506,
+                0.01560221,
+                0.01645565,
+                0.01905045,
+                0.02185605,
+                0.0237823,
+                0.02756447,
+                0.02871819,
+            ],
+            [
+                0.00898803,
+                0.01011504,
+                0.01083064,
+                0.01324822,
+                0.01399304,
+                0.01624022,
+                0.01859142,
+                0.02021879,
+                0.02353281,
+                0.02476247,
+            ],
+            [
+                0.00761489,
+                0.00856395,
+                0.00915069,
+                0.01115789,
+                0.01175641,
+                0.01367194,
+                0.01555408,
+                0.01687476,
+                0.01977919,
+                0.02083874,
+            ],
+            [
+                0.0053385,
+                0.00607165,
+                0.00645562,
+                0.00788072,
+                0.00832072,
+                0.00965699,
+                0.01112998,
+                0.01212624,
+                0.01416395,
+                0.01507873,
+            ],
+            [
+                0.00346535,
+                0.00388841,
+                0.00413141,
+                0.00503782,
+                0.00531415,
+                0.00617108,
+                0.00703597,
+                0.0076569,
+                0.00886423,
+                0.00926628,
+            ],
+            [
+                0.00287961,
+                0.00325313,
+                0.00348236,
+                0.00426688,
+                0.00449341,
+                0.00521093,
+                0.00598266,
+                0.00652404,
+                0.00755206,
+                0.00792809,
+            ],
+            [
+                0.00243663,
+                0.00272667,
+                0.00291825,
+                0.00356491,
+                0.00376222,
+                0.00435947,
+                0.00498846,
+                0.00541417,
+                0.006259,
+                0.00656881,
+            ],
+            [
+                0.00171876,
+                0.00193273,
+                0.00206471,
+                0.00252897,
+                0.00267032,
+                0.00309158,
+                0.00354587,
+                0.00384297,
+                0.00443779,
+                0.00471282,
+            ],
+            [
+                0.00109227,
+                0.00124015,
+                0.00131565,
+                0.00159665,
+                0.00168592,
+                0.00195405,
+                0.00224709,
+                0.00242406,
+                0.00276954,
+                0.00293705,
+            ],
+        ]
+    )
+
+    n = len(seq)
+    D = float(dip(seq))
+
+    i1 = int(torch.searchsorted(N_VALS, n))
+    i0 = int(i1 - 1)
+
+    # if n falls outside range of n_vals, select endpoint instead
+    if i0 < 0:
+        i0 = 0
+    if i1 > (len(N_VALS) - 1):
+        i1 = len(N_VALS) - 1
+
+    n0 = N_VALS[i0]
+    n1 = N_VALS[i1]
+    if n0 != n1:
+        fn = 1.0 * (n - n0) / (n1 - n0)
+    else:
+        fn = torch.tensor([0.0])  # catch div-by-zero in endpoint case
+    sn = torch.sqrt(torch.tensor([n]))
+    y0 = sn * CRIT_VALS[i0]
+    y1 = sn * CRIT_VALS[i1]
+    sD = sn * D
+
+    p = 1.0 - interp(sD, y0 + fn * (y1 - y0), P_VALS)
+    return D, p
+
+
+def interp(x_new: torch.Tensor, x_vals: torch.Tensor, f_vals: torch.Tensor) -> float:
+    """
+    Simple interpolation scheme to estimate the value at `x_new` of a function
+    with values `f_vals` at points `x_vals`. If `x_new` falls within the range of
+    `x_vals`, use linear interpolation. Otherwise use constant interpolation from the
+    nearest observed point.
+
+    :param x_new: Point at which to approximate the function.
+    :type x_new: torch.Tensor
+    :param x_vals: Observed input values.
+    :type xx_valsp: torch.Tensor
+    :param f_vals: Observed function values corresponding to each input value.
+    :type f_vals: torch.Tensor
+    :return: Estimated value of the function at `x`.
+    :rtype: float
+    """
+
+    if torch.all(x_new < x_vals):  # constant interp. outside observed range
+        return float(f_vals[0])
+    elif torch.all(x_new > x_vals):
+        return float(f_vals[-1])
+    else:
+        m = (f_vals[1:] - f_vals[:-1]) / (x_vals[1:] - x_vals[:-1])
+        b = f_vals[:-1] - (m * x_vals[:-1])
+
+        i1 = torch.searchsorted(x_vals, x_new) - 1
+        i1 = torch.clamp(i1, 0, len(m) - 1)
+
+        return float(m[i1] * x_new + b[i1])
diff --git a/opensearch_py_ml/ml_models/metrics_correlation/event_detection.py b/opensearch_py_ml/ml_models/metrics_correlation/event_detection.py
new file mode 100644
index 000000000..f9395d1a4
--- /dev/null
+++ b/opensearch_py_ml/ml_models/metrics_correlation/event_detection.py
@@ -0,0 +1,257 @@
+# SPDX-License-Identifier: Apache-2.0
+# The OpenSearch Contributors require contributions made to
+# this file be licensed under the Apache-2.0 license or a
+# compatible open source license.
+# Any modifications Copyright OpenSearch Contributors. See
+# GitHub history for details.
+
+from typing import Dict, List
+
+import torch
+
+from .dip import diptest
+
+
+def find_events(
+    patterns: torch.Tensor, dt_pval: float
+) -> List[Dict[str, torch.Tensor]]:
+    """
+    Select events from the set of patterns obtained by decomposition
+    of the activity scores.
+
+    :param patterns: Tensor of shape (p, T), with $p$ the number of patterns
+        and $T$ the pattern length.
+    :type patterns: torch.Tensor
+    :param dt_pval: P-value threshold for event detection. Passed to the dip
+        test.
+    :type dt_pval: float
+    :return: List of detected events. Elements of the list are
+        dictionaries with keys 'range' and 'event'; 'range' gives the interval
+        over which the event occurred, and 'event' is a length-T array of the
+        event intensity.
+    :rtype: List[int, Dict[str, torch.Tensor]]
+    """
+
+    candidates = find_unimodal_events(patterns, dt_pval)
+    events = merge_events(candidates)
+    return events
+
+
+def find_unimodal_events(patterns: torch.Tensor, dt_pval: float) -> torch.Tensor:
+    """
+    Given a set of patterns, return those that are unimodal. Unimodality is
+    evaluated via the dip test.
+
+    :param patterns: Tensor of shape (p, T), with $p$ the number of patterns
+        and $T$ the pattern length.
+    :type patterns: torch.Tensor
+    :param dt_pval: P-value threshold for event detection. Passed to the dip
+        test.
+    :type dt_pval: float
+    :return: Tensor of shape (p_u, T), with $p_u$ the number of unimodal
+        patterns detected.
+    :rtype: torch.Tensor
+    """
+    E = patterns.shape[0]
+    dt_pvals = torch.zeros(E)
+
+    for e in range(E):
+        _, pval = diptest(patterns[e, :])
+        dt_pvals[e] = pval
+
+    return patterns[dt_pvals > dt_pval, :]
+
+
+def merge_events(
+    candidates: torch.Tensor,
+) -> List[Dict[str, torch.Tensor]]:
+    """
+    Merge candidate events if they have sufficient overlap. Returns a
+    structured object containing all final events, with information on
+    their duration and intensity.
+
+    :param candidates: Tensor of shape (p, T), with $p$ the number of
+        candidate (i.e. unimodal) patterns and $T$ the pattern length.
+    :type candidates: torch.Tensor
+    :return: List of detected events. Elements of the list are
+        dictionaries with keys 'event_window' and 'event_pattern';
+        'event_window' gives the interval over which the event occurred,
+        and 'event_pattern' is a length-T array of the event intensity.
+    :rtype: List[int, Dict[str, torch.Tensor]]
+    """
+    E, T = candidates.shape
+
+    starts = torch.zeros(E)
+    ends = torch.zeros(E)
+    for e in range(E):  # define event start and end by cumsum quantiles
+        event = candidates[e, :]
+        ecdf = torch.cumsum(event, dim=0) / torch.sum(event)
+        starts[e] = torch.sum(ecdf < 0.1)
+        ends[e] = torch.sum(ecdf < 0.9)
+    ix = torch.argsort(starts)
+
+    sstart = starts[ix]
+    send = ends[ix]
+    sevents = candidates[ix, :]
+
+    merged: List[
+        Dict[str, torch.Tensor]
+    ] = []  # merge in linear pass over time dimension
+    currstart = torch.tensor([-1])
+    currend = torch.tensor([-1])
+    currevent = torch.ones(T) * -1.0
+
+    for i, s in enumerate(sstart):
+        if i == 0:  # first event
+            currstart = s
+            currend = send[i]
+            currevent = sevents[i, :]
+        elif s > currend:  # start of new event; record previous one
+            merged.append(
+                {
+                    "event_window": torch.stack([currstart, currend]),
+                    "event_pattern": currevent,
+                }
+            )
+            currstart = s
+            currend = send[i]
+            currevent = sevents[i, :]
+        else:  # update current event
+            currend = torch.max(torch.stack([currend, send[i]]))
+            currevent += sevents[i, :]
+
+    # record final event
+    if currstart > 0:
+        merged.append(
+            {
+                "event_window": torch.stack([currstart, currend]),
+                "event_pattern": currevent,
+            }
+        )
+    return merged
+
+
+def assign_metrics_to_events(
+    events: List[Dict[str, torch.Tensor]],
+    activity_scores: torch.Tensor,
+    omp_tol: float,
+) -> List[Dict[str, torch.Tensor]]:
+    """
+    Given a set of activity scores and the events detected, assign each
+    activity score (i.e. metric) to an event. Each metric can be assigned
+    to zero, one, or multiple events.
+
+
+    :param events: Events detected in the activity score data.
+    :type events: List[int, Dict[str, torch.Tensor]]
+    :param activity_scores: The activiy scores from which the events were
+        computed.
+    :type activity_scores: torch.Tensor
+    :param omp_tol: Threshold for event assignment via orthogonal matching
+        pursuit. Each next event is assigned to the metric if it explains a
+        proportion of the residual variance at least equal to `omp_tol`.
+    :type omp_tol: float
+    :return: A list with the same structure as the `events` parameter,
+        where each element (event) now also has a 'metrics' field listing the
+        indices of the metrics assigned to that event.
+    :rtype: List[int, Dict[str, torch.Tensor]]
+    """
+    (
+        M,
+        T,
+    ) = activity_scores.shape  # loop over metrics, getting their event assignments
+
+    massign = {m: omp_assign(events, activity_scores[m, :], omp_tol) for m in range(M)}
+
+    # invert this to get list of metrics per event
+    evs: List[int] = []
+    for v in massign.values():
+        for ev in v:
+            if ev not in evs:
+                evs += [ev]
+
+    for e in evs:
+        mlist: List[int] = []
+        for k, v in massign.items():
+            if int(e) in v:
+                mlist += [k]
+
+        events[int(e)]["suspected_metrics"] = torch.tensor(mlist)
+
+    return events
+
+
+def omp_assign(
+    events: List[Dict[str, torch.Tensor]],
+    activity_score: torch.Tensor,
+    omp_tol: float,
+) -> List[int]:
+    """
+    Orthogonal matching pursuit for event assignment. Assignment is defined in
+    terms of explanation: a metric is assigned to an event if that event
+    explains a significant fraction of the metric's activity score. For each
+    metric, the set of explanatory events is determined by orthogonal matching
+    pursuit regression of the activity score against the set of event
+    intensities.
+
+    :param events: The events used to explain a metric's activity score.
+        Assumed to have the same structure as the output of `find_events()`.
+    :type events: dict[int, dict[str, torch.Tensor]]
+    :param activity_score: 1-D activity score of the metric.
+    :type activity_score: torch.Tensor
+    :param omp_tol: Threshold for event assignment via orthogonal matching
+        pursuit. Each next event is assigned to the metric if it explains a
+        proportion of the residual variance at least equal to `omp_tol`.
+    :type omp_tol: float
+    :return: List of events assigned to the metric.
+    :rtype: List[int]
+    """
+
+    if torch.var(activity_score, dim=0, unbiased=False) == 0:
+        return []  # skip assignment if activity score is flat
+
+    # initialize for OMP loop
+    assign: List[int] = []
+    assigned_events = torch.empty((len(activity_score), 0))
+    cvg = False
+    events_left: List[int] = torch.arange(len(events)).tolist()
+    r = activity_score
+
+    if len(events_left) == 0:  # case where no events were found upstream
+        return assign
+
+    while not cvg:
+        # get remaining event patterns
+        event_mat = torch.stack(
+            [events[i]["event_pattern"] for i in events_left], dim=1
+        )
+        event_mat /= torch.linalg.norm(event_mat, dim=0)
+
+        # find event that best explains activity score remainder
+        corrs = torch.matmul(event_mat.t(), r)
+        eix = int(torch.argmax(torch.abs(corrs)))
+
+        # add it if it sufficiently reduces approximation error
+        assigned_events_trial = torch.cat(
+            (assigned_events, event_mat[:, eix].unsqueeze(1)), dim=1
+        )
+        coef = torch.linalg.solve(
+            torch.mm(assigned_events_trial.t(), assigned_events_trial),
+            torch.matmul(assigned_events_trial.t(), activity_score),
+        )
+        rnew = activity_score - torch.matmul(assigned_events_trial, coef)
+
+        rel_sqerr_decrease = torch.sum(rnew**2) / torch.sum(r**2)
+        if rel_sqerr_decrease < omp_tol:
+            r = rnew
+            assign += [events_left[eix]]
+            assigned_events = assigned_events_trial
+            events_left.pop(eix)
+
+            if len(events_left) == 0:
+                cvg = True  # end if no events remain
+
+        else:
+            cvg = True  # no remaining events sufficiently explain data
+
+    return assign
diff --git a/opensearch_py_ml/ml_models/metrics_correlation/mcorr.py b/opensearch_py_ml/ml_models/metrics_correlation/mcorr.py
new file mode 100644
index 000000000..cb1656c78
--- /dev/null
+++ b/opensearch_py_ml/ml_models/metrics_correlation/mcorr.py
@@ -0,0 +1,85 @@
+# SPDX-License-Identifier: Apache-2.0
+# The OpenSearch Contributors require contributions made to
+# this file be licensed under the Apache-2.0 license or a
+# compatible open source license.
+# Any modifications Copyright OpenSearch Contributors. See
+# GitHub history for details.
+from typing import Dict, List
+
+import torch
+
+from .event_detection import assign_metrics_to_events, find_events
+from .nmf import NMF
+from .utils import BANDWIDTH, DT_PVAL, NMF_MAX_ITER, NMF_TOL, OMP_TOL
+from .wavelet_piecewise_const import transform_piecewise_const, wavelet_piecewise_const
+
+
+class MCorr(torch.nn.Module):
+    def __init__(self) -> None:
+        super().__init__()
+        self.bandwidth = BANDWIDTH
+        self.nmf_max_iter = NMF_MAX_ITER
+        self.nmf_tol = NMF_TOL
+        self.NMF = NMF()
+
+        self.dt_pval = DT_PVAL
+        self.omp_tol = OMP_TOL
+
+    def forward(
+        self, metrics: torch.Tensor, max_events: int = 5, wavelet_approx: bool = True
+    ) -> List[Dict[str, torch.Tensor]]:
+        """
+        Main entry point of the Metrics Correlation algorithm.
+
+        :param metrics:
+        :type metrics: torch.Tensor
+        :param max_events:
+        :type max_events: int
+        :param wavelet_approx
+        :type wavelet_approx: int
+        :return:
+        :rtype: List[int, Dict[str, torch.Tensor]]
+        """
+        # Validate inputs
+        M, T = metrics.shape
+        if M >= T:
+            raise ValueError(
+                "The number of metrics to correlate must be smaller than the length of each time series."
+            )
+
+        #
+        # Step 1: piecewise constant approx. and activity scoring for input metrics
+        #
+        num_metrics, T = metrics.shape
+        max_events = min(max_events, num_metrics)
+        self.bandwidth = float(max(T // 32, 1))
+        activity_scores = self.compute_activity_scores(
+            metrics, max_events, wavelet_approx
+        )
+
+        #
+        # Step 2: event detection and metrics assignment
+        #
+        loadings, patterns = self.NMF(
+            activity_scores,
+            max_events,
+            max_iter=self.nmf_max_iter,
+            tol=self.nmf_tol,
+        )
+
+        events = find_events(patterns, dt_pval=self.dt_pval)
+        events = assign_metrics_to_events(events, activity_scores, omp_tol=self.omp_tol)
+
+        return events
+
+    def compute_activity_scores(
+        self, metrics: torch.Tensor, max_events: int, wavelet_approx: bool
+    ) -> torch.Tensor:
+        M, T = metrics.shape
+        activity_scores = torch.zeros((M, T - 1))
+        for m in range(
+            M
+        ):  # NOTE: ideally we'd be able to parallelize this, as in the non-script version
+            apx = wavelet_piecewise_const(metrics, m, 2 * max_events, wavelet_approx)
+            activity_scores[m, :] = transform_piecewise_const(apx, self.bandwidth)
+        return activity_scores
diff --git a/opensearch_py_ml/ml_models/metrics_correlation/nmf.py b/opensearch_py_ml/ml_models/metrics_correlation/nmf.py
new file mode 100644
index 000000000..66aedb281
--- /dev/null
+++ b/opensearch_py_ml/ml_models/metrics_correlation/nmf.py
@@ -0,0 +1,141 @@
+# SPDX-License-Identifier: Apache-2.0
+# The OpenSearch Contributors require contributions made to
+# this file be licensed under the Apache-2.0 license or a
+# compatible open source license.
+# Any modifications Copyright OpenSearch Contributors. See
+# GitHub history for details.
+
+from typing import Tuple
+
+import torch
+
+
+class NMF(torch.nn.Module):
+    def __init__(self) -> None:
+        super().__init__()
+
+    def step(
+        self, V: torch.Tensor, W: torch.Tensor, H: torch.Tensor
+    ) -> Tuple[torch.Tensor, torch.Tensor]:
+        """
+        Multiplicative update; see https://arxiv.org/pdf/1010.1763.pdf
+        Variable definitions here are consistent with notation in this paper.
+
+        Implementation follows sklearn 'mu' type updates, see here:
+        https://github.com/scikit-learn/scikit-learn/blob/36958fb240fbe435673a9e3c52e769f01f36bec0/sklearn/decomposition/_nmf.py#L540-L742
+
+        Note that these proceed in W, H order, as opposed to H, W in the paper.
+
+        :param V: V is a data metrics of dimensions F × N with non-negative entries
+        :type V: torch.Tensor
+        :param W: W is a data metrics of dimensions F × K with non-negative entries
+        :type W: torch.Tensor
+        :param H: H is a data metrics of dimensions K × N with non-negative entries
+        :type H: torch.Tensor
+        :return:
+        :rtype: Tuple[torch.Tensor, torch.Tensor]
+        """
+        # W update
+        VHt = torch.mm(V, H.t())
+        WHHt = torch.mm(W, torch.mm(H, H.t()))
+        WHHt[WHHt == 0] = 1.2e-7
+        W = torch.mul(W, torch.div(VHt, WHHt))  # note these ops are element-wise
+
+        # H update
+        WtV = torch.mm(W.t(), V)
+        WtWH = torch.mm(torch.mm(W.t(), W), H)
+        WtWH[WtWH == 0] = 1.2e-7
+        H = torch.mul(H, torch.div(WtV, WtWH))
+
+        return W, H
+
+    def initialize(self, V, k: int) -> Tuple[torch.Tensor, torch.Tensor]:
+        """
+        'NNDSVD-A' initialization, as in sklearn.
+
+        For nonnegative SVD initialization see https://www.cb.uu.se/~milan/histo/before2011august/Boutsidis.pdf
+
+        NNDSVD-A first computes NNDSVD as above, then fills any zeros in W or H with the mean of V.
+
+        :param V:
+        :type V: torch.Tensor
+        :param k:
+        :type k: int
+        :return:
+        :rtype: Tuple[torch.Tensor, torch.Tensor]
+        """
+        U, S, Vh = torch.linalg.svd(V, full_matrices=False)
+
+        d = U.shape[1]  # factors are identified up to sign. make sure they're positive
+        for dd in range(d):
+            if torch.any(U[:, dd] < 0):
+                U[:, dd] *= -1
+                Vh[dd, :] *= -1
+
+        k = int(k)
+
+        f, n = V.shape
+
+        W = torch.zeros((f, k))
+        H = torch.zeros((k, n))
+
+        W[:, 0] = torch.sqrt(S[0]) * U[:, 0]
+        H[0, :] = torch.sqrt(S[0]) * Vh[0, :]
+
+        for j in range(1, k):
+            xp = torch.nn.functional.relu(U[:, j])
+            xn = torch.nn.functional.relu(-U[:, j])
+            yp = torch.nn.functional.relu(Vh[j, :])
+            yn = torch.nn.functional.relu(-Vh[j, :])
+
+            mp = torch.norm(xp) * torch.norm(yp)
+            mn = torch.norm(xn) * torch.norm(yn)
+            if mp > mn:
+                u = xp / torch.norm(xp)
+                v = yp / torch.norm(yp)
+                sig = mp
+            else:
+                u = xn / torch.norm(xn)
+                v = yn / torch.norm(yn)
+                sig = mn
+
+            W[:, j] = torch.sqrt(S[j] * sig) * u
+            H[j, :] = torch.sqrt(S[j] * sig) * v
+
+        W[W == 0] = V.mean()
+        H[H == 0] = V.mean()
+
+        return W, H
+
+    def forward(
+        self, V: torch.Tensor, k: int, max_iter: int, tol: float
+    ) -> Tuple[torch.Tensor, torch.Tensor]:
+        """
+
+        :param V:
+        :type V: torch.Tensor
+        :param k:
+        :type k: int
+        :param max_iter:
+        :type max_iter: int
+        :param tol:
+        :type tol: float
+        :return:
+        :rtype: Tuple[torch.Tensor, torch.Tensor]
+        """
+        W, H = self.initialize(V, k)  # NNDSVD-A init
+        init_err = torch.linalg.norm(V - torch.mm(W, H))
+        prev_err = init_err
+
+        for i in range(1, max_iter + 1):
+            W, H = self.step(V, W, H)
+
+            if i % 10 == 0:  # check rel err stopping criterion
+                err = torch.linalg.norm(V - torch.mm(W, H))
+                if (prev_err - err) / init_err < tol:
+                    break
+                prev_err = err
+
+            # TODO: convergence warning for case i == max_iter:
+
+        return W, H
diff --git a/opensearch_py_ml/ml_models/metrics_correlation/utils.py b/opensearch_py_ml/ml_models/metrics_correlation/utils.py
new file mode 100644
index 000000000..b9f43e20d
--- /dev/null
+++ b/opensearch_py_ml/ml_models/metrics_correlation/utils.py
@@ -0,0 +1,12 @@
+# SPDX-License-Identifier: Apache-2.0
+# The OpenSearch Contributors require contributions made to
+# this file be licensed under the Apache-2.0 license or a
+# compatible open source license.
+# Any modifications Copyright OpenSearch Contributors. See
+# GitHub history for details.
+
+BANDWIDTH = 16.0
+NMF_MAX_ITER = 300
+NMF_TOL = 1e-4
+DT_PVAL = 0.5
+OMP_TOL = 0.8
diff --git a/opensearch_py_ml/ml_models/metrics_correlation/wavelet_piecewise_const.py b/opensearch_py_ml/ml_models/metrics_correlation/wavelet_piecewise_const.py
new file mode 100644
index 000000000..cab1636d2
--- /dev/null
+++ b/opensearch_py_ml/ml_models/metrics_correlation/wavelet_piecewise_const.py
@@ -0,0 +1,317 @@
+# SPDX-License-Identifier: Apache-2.0
+# The OpenSearch Contributors require contributions made to
+# this file be licensed under the Apache-2.0 license or a
+# compatible open source license.
+# Any modifications Copyright OpenSearch Contributors. See
+# GitHub history for details.
+
+"""wavelet_piecewise_const
+
+Tools for fast piecewise-constant signal approximation from Haar wavelet reconstruction.
+
+Functions
+---------
+piecewise_const_to_segs
+    Segment-based representation of a piecewise constant signal.
+
+blockwise_dp_segment
+    DP-based piecewise constant approximation of a piecewise-constant signal.
+
+blockwise_seg_to_approx
+    Convert output of blockwise_dp_segment to a signal-length approximation.
+
+lavielle_criterion
+    Criterion for selecting number of segments.
+
+wavelet_piecewise_const
+    Fast piecewise constant approximation via Haar wavelet reconstruction.
+
+tranform_piecewise_const
+    Convert piecewise constant signal to activity score.
+"""
+import math
+from typing import Dict, List, Tuple
+
+import torch
+import torch.nn.functional as F
+
+from .wavelet_tools import haar_approx
+
+
+def wavelet_piecewise_const(
+    data: torch.Tensor, m: int, max_dp_levels: int, is_haar_approx: bool
+) -> torch.Tensor:
+    """
+    Fast, wavelet-based piecewise constant signal approximation for row m of the M x T input data tensor.
+    :param data:
+    :type data: torch.Tensor
+    :param m:
+    :type m: int
+    :param max_dp_levels
+    :type max_dp_levels: int
+    :param is_haar_approx
+    :type is_haar_approx: bool
+    :return:
+    :rtype: torch.Tensor
+    """
+    seq = data[m, :]
+    if (torch.abs(torch.diff(seq)) > 0).sum() == 0:
+        return seq  # avoid cost and numerical errors from needless approx
+    elif is_haar_approx:  # compute Haar piecewise constant approx
+        N = len(seq)
+        J = math.ceil(math.log(N) / math.log(2))
+        wav_approx = haar_approx(seq, truncate=J * max_dp_levels)
+    else:
+        wav_approx = seq  # no wavelet approximation -> slower DP
+
+    # refine with block wise DP
+    segs, costs = block_wise_dp_segment(wav_approx, max_dp_levels)
+
+    # select segmentation level with lavielle criterion
+    nseg, _ = lavielle_criterion(costs[:, -1])
+
+    return block_wise_seg_to_approx(wav_approx, segs[nseg])
+
+
+def block_wise_dp_segment(
+    data: torch.Tensor, max_lvls: int
+) -> Tuple[Dict[int, List[int]], torch.Tensor]:
+    """
+    Piecewise constant approximation to a piecewise constant signal, i.e. the best approximation at a more granular
+        (fewer segments) resolution, via dynamic programming.
+    :param data:
+    :type data: torch.Tensor
+    :param max_lvls:
+    :type max_lvls: int
+    :return:
+    :rtype: Tuple[Dict[int, List[int]], torch.Tensor]
+    """
+
+    vals, counts = piecewise_const_to_segs(data)
+    ssum: List[float] = [
+        float(x) for x in torch.cumsum(vals * counts, 0)
+    ]  # need to do this as tensor.tolist() gives a torch.jit runtime error
+    sqsum: List[float] = [
+        float(x) for x in torch.cumsum(torch.pow(vals, 2) * counts, 0)
+    ]
+    scounts: List[float] = [float(x) for x in torch.cumsum(counts, 0)]
+
+    N = len(vals)
+    segs = {int(i + 1): [0] for i in range(max_lvls)}
+    costs = torch.zeros((max_lvls, N))
+
+    # initialize cost and segmentation in base case w/ b=0 (no jumps)
+    segs[1] = [0, int(N)]
+    best_last_segs = [[0]] * N
+
+    costs[0, :] = torch.zeros(N)
+    for i in range(N):
+        costs[0, i] = sqerr_seg_list(0, i, ssum, sqsum, scounts)
+
+    # recursion
+    for b in range(1, max_lvls):
+        best_segs: List[List[int]] = []
+
+        for i in range(N):
+            if i <= b:  # more breaks than segs in data -> 0 cost
+                seg_i: List[int] = []
+                for x in range(
+                    i + 1
+                ):  # expand list comp here as torchscript can't handle it
+                    seg_i += [x]
+                best_segs += [seg_i]
+                costs[b, i] = 0
+
+            else:  # DP recursion
+                addcosts = torch.zeros(i)
+                for j in range(i):
+                    addcosts[j] = sqerr_seg_list(j + 1, i, ssum, sqsum, scounts)
+                new_jump_costs = costs[b - 1, :i] + addcosts
+
+                new_jump_ix = int(torch.argmin(new_jump_costs))
+                new_jump_cost = new_jump_costs[new_jump_ix]
+
+                best_segs += [best_last_segs[new_jump_ix] + [new_jump_ix + 1]]
+                costs[b, i] = new_jump_cost
+
+        best_last_segs = best_segs
+        segs[b + 1] = best_segs[-1] + [int(N)]
+
+    return segs, costs
+
+
+def lavielle_criterion(costs: torch.Tensor) -> Tuple[int, torch.Tensor]:
+    """
+    Criterion to automatically select number of segments in a piecewise constant approximation.
+        Reference: https://hal.inria.fr/inria-00070662/document
+    :param costs:
+    :type costs: torch.Tensor
+    :return:
+    :rtype: Tuple[int, torch.Tensor]
+    """
+    s = 0.75  # hardcode cutoff at paper suggestion
+    kmax = len(costs)
+    normcosts = (costs[-1] - costs) / (costs[-1] - costs[0]) * (kmax - 1) + 1
+
+    # catch NaNs arising from div-by-zero above
+    # only happens when cost[1] = cost[kmax], so correct choice is 1 segment
+    normcosts[normcosts.isnan()] = -1
+
+    d = torch.zeros(len(costs))
+    d[0] = (
+        4 * torch.max(torch.abs(normcosts)) + 1.1 * s
+    )  # upper bound and always strictly > s
+    for i in range(1, len(costs) - 1):
+        d[i] = (
+            normcosts[i - 1] - 2 * normcosts[i] + normcosts[i + 1]
+        )  # discrete 2nd deriv
+
+    return int(torch.max(torch.nonzero(d > s)[:, 0]) + 1), d  # largest k s.t. d[k]>s
+
+
+def block_wise_seg_to_approx(data: torch.Tensor, seg: List[int]) -> torch.Tensor:
+    """
+    Convert blockwise DP segmentation output to piecewise constant approximation.
+    :param costs:
+    :type costs: torch.Tensor
+    :return:
+    :rtype: Tuple[int, torch.Tensor]
+    """
+    vals, counts = piecewise_const_to_segs(data)
+    scounts = F.pad(
+        torch.cumsum(counts, 0), (1, 0)
+    )  # prepend zero to get indices right
+
+    apx = torch.zeros(len(data))
+    for i in range(1, len(seg)):
+        e = seg[i]  # DP segments are left-closed, right-open
+        s = seg[i - 1]
+
+        eix = scounts[e]
+        six = scounts[s]
+
+        apx[six:eix] = torch.mean(data[six:eix])
+
+    return apx
+
+
+def piecewise_const_to_segs(signal: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
+    """
+    Represent a piecewise-constant signal in terms of its segments, each given by a value and length.
+    :param signal:
+    :type signal: torch.Tensor
+    :return: output -> the output list of unique scalar elements, counts ->
+        represents the number of occurrences for each unique value or tensor.
+    :rtype: torch.Tensor, torch.Tensor
+    """
+    vals, counts = torch.unique_consecutive(signal, return_counts=True)
+    return vals, counts
+
+
+def sqerr_seg_list(
+    st: int, e: int, ssum: List[float], sqsum: List[float], scounts: List[float]
+) -> float:
+    """
+    Blockwise version of square loss.
+    :param st:
+    :type st: int
+    :param e:
+    :type e: int
+    :param ssum:
+    :type ssum: List[float]
+    :param sqsum:
+    :type sqsum: List[float]
+    :param scounts:
+    :type scounts: List[float]
+    :return:
+    :rtype:
+    """
+
+    assert e >= st
+    s = ssum[e] - ssum[st - 1] if st > 0 else ssum[e]
+    sq = sqsum[e] - sqsum[st - 1] if st > 0 else sqsum[e]
+    n = scounts[e] - scounts[st - 1] if st > 0 else scounts[e]
+    return sq - (1 / n) * s**2
+
+
+def transform_piecewise_const(sig: torch.Tensor, bandwidth: float) -> torch.Tensor:
+    """
+    Convert piecewise constant approximation to activity score.
+    :param sig: signal
+    :type sig: torch.Tensor
+    :param bandwidth:
+    :type bandwidth: float
+    :return:
+    :rtype: torch.Tensor
+    """
+    sigdiff = torch.abs(torch.diff(sig))
+
+    # logic required to avoid bad representation of spikes
+    chg = torch.where(sigdiff > 0)[0]
+    for ix in chg:
+        if torch.any(chg == ix + 1):
+            sigdiff[ix + 1] = 0
+
+    m = torch.linalg.norm(sigdiff)
+    if m != 0:
+        sigdiff /= m  # note this may amplify small variations
+
+    blurred = gconv(sigdiff, bandwidth)
+    return blurred
+
+
+def gconv(data: torch.Tensor, bandwidth: float) -> torch.Tensor:
+    """
+    1-D convolution with boundary padding.
+    :param data:
+    :type data: torch.Tensor
+    :param bandwidth:
+    :type bandwidth: float
+    :return:
+    :rtype: torch.Tensor
+    """
+
+    filt = gfilt(bandwidth)
+    padlen = len(filt) // 2
+    padded = boundary_pad(data, (padlen, padlen))
+
+    spdat = torch.unsqueeze(torch.unsqueeze(padded, 0), 0)
+    sfilt = torch.unsqueeze(torch.unsqueeze(filt, 0), 0)
+    res = F.conv1d(spdat, sfilt)
+    return res.squeeze()
+
+
+def gfilt(sigma: float, truncate: float = 4.0) -> torch.Tensor:
+    """
+    Pytorch Gaussian filter with truncation.
+    :param sigma: sigma
+    :type sigma: float
+    :param truncate:
+    :type truncate: float, default value 4.0
+    :return:
+    :rtype: torch.Tensor
+    """
+
+    lw = int(truncate * sigma + 0.5)
+    xs = torch.arange(-lw, lw + 1)
+    sigma2 = math.pow(sigma, 2)
+    weights = torch.exp(-0.5 / sigma2 * torch.pow(xs, 2))
+    weights = weights / torch.sum(weights)
+
+    return weights
+
+
+def boundary_pad(sig: torch.Tensor, lens: List[int]) -> torch.Tensor:
+    """
+    Pad signal by extending value at boundary ('nearest' in scipy).
+    :param sig: signal
+    :type sig: float
+    :param lens:
+    :type lens: List[int]
+    :return:
+    :rtype: torch.Tensor
+    """
+
+    lpadded = F.pad(sig, (lens[0], 0), mode="constant", value=sig[0])
+    padded = F.pad(lpadded, (0, lens[1]), mode="constant", value=sig[-1])
+    return padded
diff --git a/opensearch_py_ml/ml_models/metrics_correlation/wavelet_tools.py b/opensearch_py_ml/ml_models/metrics_correlation/wavelet_tools.py
new file mode 100644
index 000000000..2bf151754
--- /dev/null
+++ b/opensearch_py_ml/ml_models/metrics_correlation/wavelet_tools.py
@@ -0,0 +1,132 @@
+# SPDX-License-Identifier: Apache-2.0
+# The OpenSearch Contributors require contributions made to
+# this file be licensed under the Apache-2.0 license or a
+# compatible open source license.
+# Any modifications Copyright OpenSearch Contributors. See
+# GitHub history for details.
+
+"""wavelet_tools
+
+Tools for Haar discrete wavelet transform and signal reconstruction.
+
+Functions
+---------
+circularize
+    Circularizes a given filter to a specified length T.
+
+haar_matrix
+    Construct the DWT matrix for a Haar wavelet decomposition.
+
+haar_approx
+    Piecewise constant approximation of a signal by reconstruction
+    from a truncated Haar wavelet representation.
+"""
+
+import math
+
+# imports
+import torch
+import torch.nn.functional as F
+
+
+def haar_approx(signal: torch.Tensor, truncate: int) -> torch.Tensor:
+    """
+    Generate the Haar DWT matrix for a given signal length (must be a power of 2).
+
+    :param signal:
+    :type signal: torch.Tensor
+    :param truncate:
+    :type truncate:  int
+    :return: Haar DWT matrix
+    :rtype: torch.Tensor
+    """
+    N = len(signal)
+    J = math.ceil(
+        math.log(N) / math.log(2)
+    )  # change of base req'd as log2 not supported in torchscript
+    is_pow2 = math.floor(math.log(N) / math.log(2)) == J
+
+    # zero pad signal if N is not power of 2
+    if not is_pow2:
+        signal = F.pad(signal, (0, int(2**J - N)))
+
+    # transform with Haar DWT
+    haar_mat = __haar_matrix(int(2**J))
+    haar_rep = torch.matmul(haar_mat, signal)
+
+    # truncate haar representation if specified
+    if truncate is not None:
+        if not is_pow2:
+            truncate += J  # additional slack to account for effects of zero-padding
+
+        ixs = torch.argsort(torch.pow(haar_rep, 2))
+        not_top_b = ixs[:-truncate]
+        haar_rep[not_top_b] = 0
+
+    # return piecewise constant haar approximation
+    approx = torch.matmul(haar_mat.t(), haar_rep)
+
+    return approx[:N]  # remove any zero-padding
+
+
+def __haar_matrix(T: int) -> torch.Tensor:
+    """
+    Generate the Haar DWT matrix for a given signal length (must be a power of 2).
+
+    :param T:
+    :type T: int
+    :return: Haar DWT matrix
+    :rtype: torch.Tensor
+    """
+
+    J = int(
+        math.log(T) / math.log(2)
+    )  # change of base req'd as log2 not supported in torchscript
+    T = int(T)
+    mat = torch.zeros((0, T))
+    smooth_mat = torch.eye(T)
+
+    # wavelet and scale filters are hardcoded local variables
+    wavelet = torch.FloatTensor([-1 / math.sqrt(2), 1 / math.sqrt(2)])
+    scale = torch.FloatTensor([1 / math.sqrt(2), 1 / math.sqrt(2)])
+
+    for j in range(J):
+        # circularize filters to length T / 2**j
+        t = int(T / 2**j)
+        scirc = __circularize(scale, t)
+        dcirc = __circularize(wavelet, t)
+
+        # construct detail matrix via even shifts
+        detail_mat = torch.vstack([torch.roll(dcirc, 2 * x) for x in range(t // 2)])
+
+        # update DWT matrix
+        mat = torch.vstack((mat, torch.mm(detail_mat, smooth_mat)))
+
+        # update smooth
+        new_smooth = torch.vstack([torch.roll(scirc, 2 * x) for x in range(t // 2)])
+        smooth_mat = torch.mm(new_smooth, smooth_mat)
+
+    return torch.vstack(
+        (mat, smooth_mat)
+    )  # add final smooth resolution before returning
+
+
+def __circularize(signal: torch.Tensor, T: int) -> torch.Tensor:
+    """
+    This method circularize a given signal to length T.
+
+    :param signal: signal
+    :type signal: torch tensor
+    :param T: signal
+    :type T: int
+    """
+
+    N = len(signal)
+
+    if T >= N:  # T >= length of the signal : circularization is just zero-padding
+        circ = F.pad(signal, (0, T - N))
+    else:  # T < length of the signal : requires periodic summation of filter
+        newlen = T * (N // T + 1)
+        circ = F.pad(signal, (0, int(newlen - N)))
+        circ = torch.sum(torch.reshape(circ, (int(N // T + 1), int(T))), dim=0)
+    return circ
diff --git a/requirements-dev.txt b/requirements-dev.txt
index d49350afc..76e8a6e04 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -18,6 +18,8 @@ pytest>=7.1.2
 pytest-mock
 pytest-cov
 nbval
+pywavelets
+scikit-learn
 
 #
 #Docs


Test patch:
diff --git a/tests/ml_models/metrics_correlation/mcorr_data.pt b/tests/ml_models/metrics_correlation/mcorr_data.pt
new file mode 100644
index 000000000..c6f7d70f5
Binary files /dev/null and b/tests/ml_models/metrics_correlation/mcorr_data.pt differ
diff --git a/tests/ml_models/metrics_correlation/test_mcorr.py b/tests/ml_models/metrics_correlation/test_mcorr.py
new file mode 100644
index 000000000..6c04f5b94
--- /dev/null
+++ b/tests/ml_models/metrics_correlation/test_mcorr.py
@@ -0,0 +1,183 @@
+# SPDX-License-Identifier: Apache-2.0
+# The OpenSearch Contributors require contributions made to
+# this file be licensed under the Apache-2.0 license or a
+# compatible open source license.
+# Any modifications Copyright OpenSearch Contributors. See
+# GitHub history for details.
+
+"""
+Tests of correctness and compilation for metrics correlation.
+"""
+
+import os
+
+import numpy as np
+import torch
+from sklearn.metrics import precision_score, recall_score
+
+import opensearch_py_ml.ml_models.metrics_correlation.mcorr as mcorr
+
+TESTDATA_FILENAME = os.path.join(
+    os.path.dirname(os.path.abspath("__file__")),
+    "tests",
+    "ml_models",
+    "metrics_correlation",
+    "mcorr_data.pt",
+)
+
+
+def test_level_invariance():
+    """
+    Results should not depend on level of metrics.
+    Test by comparing results on randomly shifted data.
+    """
+    test_data = torch.load(TESTDATA_FILENAME)["mcorr_level"]
+    model = mcorr.MCorr()
+
+    M, _ = test_data.shape
+    events_noshift = model.forward(test_data, max_events=5)
+
+    for _ in range(3):
+        shifts = torch.normal(mean=0, std=100, size=[M, 1])
+        events_shift = model.forward(test_data + shifts, max_events=5)
+
+        assert len(events_noshift) == len(events_shift)
+        for e_ns, e_s in zip(events_noshift, events_shift):
+            torch.testing.assert_close(
+                e_ns["event_window"], e_s["event_window"], atol=0, rtol=0
+            )
+            torch.testing.assert_close(
+                e_ns["suspected_metrics"], e_s["suspected_metrics"], atol=0, rtol=0
+            )
+
+
+def test_const_metrics():
+    """
+    An important property is that metrics with no variation
+    are guaranteed to never be assigned to an event.
+    """
+    test_data = torch.load(TESTDATA_FILENAME)["mcorr_const"]
+    M, T = test_data.shape
+    model = mcorr.MCorr()
+
+    # add constant metrics to normal data, check results same
+    events_before = model.forward(test_data, max_events=3)
+
+    M_const = 5
+    const_dat = torch.ones((M_const, T)) * torch.normal(0, 20, size=[M_const, 1])
+    const_ix = list(range(M, M + M_const))
+    full_data = torch.cat((test_data, const_dat), dim=0)
+
+    events_with_const = model.forward(full_data, max_events=3)
+
+    assert len(events_before) == len(events_with_const)
+    for e_b, e_wc in zip(events_before, events_with_const):
+        torch.testing.assert_close(
+            e_b["event_window"], e_wc["event_window"], atol=0, rtol=0
+        )
+        torch.testing.assert_close(
+            e_b["suspected_metrics"], e_wc["suspected_metrics"], atol=0, rtol=0
+        )
+
+        # no constant metrics in e_wc events
+        assert len(set(e_wc["suspected_metrics"]).intersection(set(const_ix))) == 0
+
+    # all-constant metrics should have no events
+    events_const = model.forward(const_dat)
+    assert len(events_const) == 0
+
+
+def test_metrics_correlation():
+    """
+    Evaluate event finding and metrics assignment.
+    """
+    test_data = torch.load(TESTDATA_FILENAME)["mcorr_test"]
+    model = mcorr.MCorr()
+
+    for dataset in test_data:
+        dat = dataset["data"]
+        M, _ = dat.shape
+
+        labels = dataset["labels"]
+
+        event_preds = model.forward(dat, max_events=5)
+
+        # correct # events
+        assert len(event_preds) == len(labels)
+
+        precs, recs = compute_event_accuracy(event_preds, labels, M)
+
+        # accurate metrics assignment per event
+        assert np.all([x > 0.7 for x in precs])
+        assert np.all([x > 0.5 for x in recs])
+
+
+def compute_event_accuracy(event_preds, event_labels, M):
+    precs = []
+    recs = []
+    for e in event_preds:
+        ran = e["event_window"]
+        lt = None
+        for k in event_labels.keys():
+            if k > ran[0] and k < ran[1]:
+                lt = k
+
+        true_mets = event_labels[lt] if lt else []
+
+        tm_bin = np.zeros(M)
+        tm_bin[true_mets] = 1
+
+        pm_bin = np.zeros(M)
+        pm_bin[e["suspected_metrics"].tolist()] = 1
+
+        precs += [precision_score(tm_bin, pm_bin)]
+        recs += [recall_score(tm_bin, pm_bin)]
+
+        return precs, recs
+
+
+def test_torchscript_scripting():
+    # test scripting and save / load of scripted model
+    model = mcorr.MCorr()
+
+    try:
+        jit_processing = torch.jit.script(model)
+    except Exception as exec:
+        assert False, f"Failed to script model with exception {exec}"
+
+    try:
+        torch.jit.save(jit_processing, "mcorrtest.pt")
+    except Exception as exec:
+        assert False, f"Failed to save model with exception {exec}"
+
+    try:
+        _ = torch.jit.load("mcorrtest.pt")
+    except Exception as exec:
+        assert False, f"Failed to load scripted model with exception {exec}"
+
+    os.remove("mcorrtest.pt")
+
+
+def test_torchscript_output():
+    # data with nontrivial event structure
+    sig = torch.normal(mean=0, std=1, size=[5, 128])
+
+    model = mcorr.MCorr()
+    pt_events = model(sig)
+
+    model = mcorr.MCorr()
+    jit_processing = torch.jit.script(model)
+    torch.jit.save(jit_processing, "mcorrtest.pt")
+    loaded_model = torch.jit.load("mcorrtest.pt")
+
+    ts_events = loaded_model.forward(sig)
+
+    # check same number of events
+    assert len(pt_events) == len(ts_events)
+
+    # check events are identical
+    for pte, tse in zip(pt_events, ts_events):
+        for k in pte.keys():
+            torch.testing.assert_close(pte[k], tse[k])
+
+    os.remove("mcorrtest.pt")
diff --git a/tests/ml_models/metrics_correlation/test_mcorr_dip_pytest.py b/tests/ml_models/metrics_correlation/test_mcorr_dip_pytest.py
new file mode 100644
index 000000000..1052d0c9d
--- /dev/null
+++ b/tests/ml_models/metrics_correlation/test_mcorr_dip_pytest.py
@@ -0,0 +1,174 @@
+# SPDX-License-Identifier: Apache-2.0
+# The OpenSearch Contributors require contributions made to
+# this file be licensed under the Apache-2.0 license or a
+# compatible open source license.
+# Any modifications Copyright OpenSearch Contributors. See
+# GitHub history for details.
+
+"""
+Tests of correctness and compilation for the dip test module
+of metrics correlation.
+
+"True" values here can be verified by comparison to established
+implementations such as the R package 'diptest'.
+"""
+
+import os
+
+import numpy as np
+import torch
+
+import opensearch_py_ml.ml_models.metrics_correlation.dip as dip
+
+
+def test_dip_statistic():
+    """
+    test correctness by computing the dip statistic on
+    various input sequences. comparisons are made to the
+    equivalent output from the R package 'diptest', the
+    canonical implementation of this method.
+    """
+
+    T = 128
+
+    # uniform eCDF
+    unifseq = torch.arange(T)
+    assert torch.abs(dip.dip(unifseq) - 0.003906) <= 1e-6
+
+    # uniform with large spike
+    unifspike = torch.arange(T)
+    unifspike[:50] = 20
+
+    assert torch.abs(dip.dip(unifspike) - 0.085426) <= 1e-6
+
+    # unimodal Gaussian
+    xs = torch.arange(-T // 2, T // 2 + 1)
+    x = torch.exp(-(xs**2) / 20)
+
+    assert torch.abs(dip.dip(x) - 0.010517) <= 1e-6
+
+    # max of 0.25 is (approx) achieved for (approx) bimodal data
+    apx_bimodal = torch.cat(
+        ((torch.normal(1.0, 0.001, [T // 2])), torch.normal(2.0, 0.001, [T // 2]))
+    )
+
+    assert dip.dip(apx_bimodal) >= 0.2475 and dip.dip(apx_bimodal) <= 0.25
+
+    # overlapping bimodal (mixture of Gaussians)
+    x_mog = torch.exp(-((xs - 5.0) ** 2) / 5) + torch.exp(-((xs + 5.0) ** 2) / 10)
+
+    assert torch.abs(dip.dip(x_mog) - 0.013016) <= 1e-6
+
+
+def test_dip_pval():
+    """
+    as above, reference values (this time for p-values)
+    are obtained from R package 'diptest'
+    """
+
+    # bimodal : p near zero
+    T = 256
+    xa = torch.arange(T + 1) / (T / 10)
+    xb = torch.arange(T + 1) / (T / 30)
+    x = torch.cat((xa - 10, xb + 15))
+    _, p = dip.diptest(x)
+
+    assert p > 0 and p < 0.01
+
+    # unimodal : p near one
+    x = torch.cat((xa + 15, xb + 5))
+    _, p = dip.diptest(x)
+
+    assert p < 1.0 and p > 0.98
+
+    # in-between cases to check interpolation
+    x = torch.cat((xa - 7, xb + 5))
+    _, p = dip.diptest(x)
+
+    assert np.abs(p - 0.57) < 0.01
+
+    T = 512
+    xa = torch.arange(T + 1) / (T / 10)
+    xb = torch.arange(T + 1) / (T / 30)
+    x = torch.cat((xa - 7, xb + 5))
+    _, p = dip.diptest(x)
+
+    assert np.abs(p - 0.09) < 0.01
+
+    T = 1024
+    xa = torch.arange(T + 1) / (T / 10)
+    xb = torch.arange(T + 1) / (T / 30)
+    x = torch.cat((xa - 6, xb + 5))
+    _, p = dip.diptest(x)
+
+    assert np.abs(p - 0.54) < 0.01
+
+
+def test_interp():
+    xs = torch.linspace(0, 1, 12) * 2 * torch.pi
+    ys = torch.sin(xs)
+
+    # should linearly interpolate within region
+    for _ in range(5):
+        newx = torch.rand([1])
+        newy = dip.interp(newx, xs, ys)
+        newy_np = np.interp(newx.numpy(), xs.numpy(), ys.numpy())
+
+        assert np.abs(newy - newy_np) <= 1e-6
+
+    # should return endpoints if outside (i.e. constant interp)
+    newy_low = dip.interp(torch.tensor([-1.0]), xs, ys)
+    newy_hi = dip.interp(torch.tensor([10.0]), xs, ys)
+
+    assert newy_low == ys[0].item()
+    assert newy_hi == ys[-1].item()
+
+
+def test_torchscript_scripting():
+    # test scripting and save / load of scripted model
+    model = DipTest()
+
+    try:
+        jit_processing = torch.jit.script(model)
+    except Exception as exec:
+        assert False, f"Failed to script model with exception {exec}"
+
+    try:
+        torch.jit.save(jit_processing, "diptest.pt")
+    except Exception as exec:
+        assert False, f"Failed to save model with exception {exec}"
+
+    try:
+        _ = torch.jit.load("diptest.pt")
+    except Exception as exec:
+        assert False, f"Failed to load scripted model with exception {exec}"
+
+    os.remove("diptest.pt")
+
+
+def test_torchscript_output():
+    # check scripted model output is same as pytorch
+    sig = torch.normal(mean=0, std=1, size=[128])
+
+    model = DipTest()
+    ptD, ptP = model(sig)
+
+    model = DipTest()
+    jit_processing = torch.jit.script(model)
+    torch.jit.save(jit_processing, "diptest.pt")
+    loaded_model = torch.jit.load("diptest.pt")
+
+    tsD, tsP = loaded_model.forward(sig)
+
+    assert np.allclose(ptD, tsD, rtol=0, atol=0)
+    assert np.allclose(ptP, tsP, rtol=0, atol=0)
+
+    os.remove("diptest.pt")
+
+
+class DipTest(torch.nn.Module):
+    def __init__(self) -> None:
+        super().__init__()
+
+    def forward(self, sig: torch.Tensor):
+        return dip.diptest(sig)
diff --git a/tests/ml_models/metrics_correlation/test_mcorr_nmf_pytest.py b/tests/ml_models/metrics_correlation/test_mcorr_nmf_pytest.py
new file mode 100644
index 000000000..438cfa37a
--- /dev/null
+++ b/tests/ml_models/metrics_correlation/test_mcorr_nmf_pytest.py
@@ -0,0 +1,117 @@
+# SPDX-License-Identifier: Apache-2.0
+# The OpenSearch Contributors require contributions made to
+# this file be licensed under the Apache-2.0 license or a
+# compatible open source license.
+# Any modifications Copyright OpenSearch Contributors. See
+# GitHub history for details.
+
+"""
+Tests of correctness and compilation for the NMF test module
+of metrics correlation.
+"""
+
+import os
+
+import numpy as np
+import torch
+from sklearn.decomposition import NMF as skNMF
+from sklearn.decomposition._nmf import _initialize_nmf
+
+import opensearch_py_ml.ml_models.metrics_correlation.nmf as nmf
+
+
+def test_nmf_init():
+    # check correctness of nndsvd-a initialization
+    T = 32
+    M = 5
+    n_component = 3
+
+    nmf_model = nmf.NMF()
+    for _ in range(5):
+        X = torch.normal(mean=0, std=1, size=[M, T]) ** 2
+
+        Wsk, Hsk = _initialize_nmf(X.numpy(), n_component, init="nndsvda")
+        Wpt, Hpt = nmf_model.initialize(X, n_component)
+
+        W_rel_err = np.linalg.norm(Wpt.numpy() - Wsk) / np.linalg.norm(Wsk)
+        H_rel_err = np.linalg.norm(Hpt.numpy() - Hsk) / np.linalg.norm(Hsk)
+
+        # check relative errors for matrix-level similarity
+        # and to avoid failing due to ignorable elementwise
+        # numerical differences
+        assert W_rel_err <= 1e-4
+        assert H_rel_err <= 1e-4
+
+
+def test_nmf_forward():
+    # compare multiplicative-update iterations
+    T = 32
+    M = 5
+    iters = [5, 20, 50]
+
+    n_component = 3
+    nmf_model = nmf.NMF()
+    sk_model = skNMF(n_components=n_component, solver="mu", init="nndsvda", max_iter=10)
+
+    for _ in range(5):
+        X = torch.normal(mean=0, std=1, size=[M, T]) ** 2
+
+        for iter in iters:
+            sk_model = skNMF(
+                n_components=n_component, solver="mu", init="nndsvda", max_iter=iter
+            )
+            Wsk = sk_model.fit_transform(X.numpy())
+            Hsk = sk_model.components_
+
+            Wpt, Hpt = nmf_model(X, k=3, max_iter=iter, tol=1e-8)
+
+            W_rel_err = np.linalg.norm(Wpt.numpy() - Wsk) / np.linalg.norm(Wsk)
+            H_rel_err = np.linalg.norm(Hpt.numpy() - Hsk) / np.linalg.norm(Hsk)
+
+            # check relative errors for matrix-level similarity
+            # and to avoid failing due to ignorable elementwise
+            # numerical differences
+            assert W_rel_err <= 0.01
+            assert H_rel_err <= 0.01
+
+
+def test_torchscript_scripting():
+    # test scripting and save / load of scripted model
+    model = nmf.NMF()
+
+    try:
+        jit_processing = torch.jit.script(model)
+    except Exception as exec:
+        assert False, f"Failed to script model with exception {exec}"
+
+    try:
+        torch.jit.save(jit_processing, "nmftest.pt")
+    except Exception as exec:
+        assert False, f"Failed to save model with exception {exec}"
+
+    try:
+        _ = torch.jit.load("nmftest.pt")
+    except Exception as exec:
+        assert False, f"Failed to load scripted model with exception {exec}"
+
+    os.remove("nmftest.pt")
+
+
+def test_torchscript_output():
+    # check scripted model output is same as pytorch
+    X = torch.normal(mean=0, std=1, size=[5, 32]) ** 2
+
+    model = nmf.NMF()
+    ptW, ptH = model(X, 3, 200, 1e-6)
+
+    model = nmf.NMF()
+    jit_processing = torch.jit.script(model)
+    torch.jit.save(jit_processing, "nmftest.pt")
+    loaded_model = torch.jit.load("nmftest.pt")
+
+    tsW, tsH = loaded_model.forward(X, 3, 200, 1e-6)
+
+    torch.testing.assert_close(ptW, tsW, rtol=0, atol=0)
+    torch.testing.assert_close(ptH, tsH, rtol=0, atol=0)
+
+    os.remove("nmftest.pt")
diff --git a/tests/ml_models/metrics_correlation/test_mcorr_pytest.py b/tests/ml_models/metrics_correlation/test_mcorr_pytest.py
new file mode 100644
index 000000000..712062526
--- /dev/null
+++ b/tests/ml_models/metrics_correlation/test_mcorr_pytest.py
@@ -0,0 +1,412 @@
+# SPDX-License-Identifier: Apache-2.0
+# The OpenSearch Contributors require contributions made to
+# this file be licensed under the Apache-2.0 license or a
+# compatible open source license.
+# Any modifications Copyright OpenSearch Contributors. See
+# GitHub history for details.
+
+
+import torch
+
+from opensearch_py_ml.ml_models import MCorr
+
+
+def test_mcorr_output():
+    mcorr = MCorr()
+    events = mcorr.forward(
+        torch.tensor(
+            [
+                [
+                    -1.1635416,
+                    -1.5003631,
+                    0.46138194,
+                    0.5308311,
+                    -0.83149344,
+                    -3.7009873,
+                    -3.5463789,
+                    0.22571462,
+                    -5.0380244,
+                    0.76588845,
+                    1.236113,
+                    1.8460795,
+                    1.7576948,
+                    0.44893077,
+                    0.7363948,
+                    0.70440894,
+                    0.89451003,
+                    4.2006273,
+                    0.3697659,
+                    2.2458954,
+                    -2.302939,
+                    -1.7706926,
+                    1.7445002,
+                    -1.5246059,
+                    0.07985192,
+                    -2.7756078,
+                    1.0002468,
+                    1.5977372,
+                    2.9152713,
+                    1.4172368,
+                    -0.26551363,
+                    -2.2883027,
+                    1.5882446,
+                    2.0145164,
+                    3.4862874,
+                    -1.2486862,
+                    -2.4811826,
+                    -0.17609037,
+                    -2.1095612,
+                    -1.2184235,
+                    0.63118523,
+                    -1.8909532,
+                    2.039797,
+                    -0.5317177,
+                    -2.2922578,
+                    -2.0179775,
+                    -0.07992507,
+                    -0.12554549,
+                    -0.2553092,
+                    1.1450123,
+                    -0.4640453,
+                    -2.190223,
+                    -4.671612,
+                    -1.5076426,
+                    1.635445,
+                    -1.1394824,
+                    -0.7503817,
+                    0.98424894,
+                    -0.38896716,
+                    1.0328646,
+                    1.9543738,
+                    -0.5236269,
+                    0.14298044,
+                    3.2963762,
+                    8.1641035,
+                    5.717064,
+                    7.4869685,
+                    2.5987444,
+                    11.018798,
+                    9.151356,
+                    5.7354255,
+                    6.862203,
+                    3.0524514,
+                    4.431755,
+                    5.1481285,
+                    7.9548607,
+                    7.4519925,
+                    6.09533,
+                    7.634116,
+                    8.898271,
+                    3.898491,
+                    9.447067,
+                    8.197385,
+                    5.8284273,
+                    5.804283,
+                    7.7688456,
+                    10.574343,
+                    7.5679493,
+                    7.1888094,
+                    7.1107903,
+                    8.454468,
+                    8.066334,
+                    8.83665,
+                    7.11204,
+                    4.4898267,
+                    8.614764,
+                    6.336754,
+                    11.577503,
+                    3.3998494,
+                    9.501525,
+                    13.17289,
+                    6.1116023,
+                    5.143777,
+                    2.7813284,
+                    3.7917604,
+                    7.1683135,
+                    7.627272,
+                    7.290255,
+                    3.1299121,
+                    7.089733,
+                    9.140584,
+                    8.844729,
+                    9.403275,
+                    10.220029,
+                    8.039719,
+                    8.85549,
+                    4.034555,
+                    4.412663,
+                    7.54451,
+                    7.2116737,
+                    4.6346946,
+                    7.0044127,
+                    9.7557,
+                    10.982841,
+                    5.897937,
+                    6.870126,
+                    3.5638695,
+                    5.7872133,
+                ],
+                [
+                    1.3037996,
+                    2.7976995,
+                    -0.12042701,
+                    1.3688855,
+                    1.6955005,
+                    -2.2575269,
+                    0.080582514,
+                    3.011721,
+                    -0.4320283,
+                    3.2440786,
+                    -1.0321085,
+                    1.2346085,
+                    -2.3152106,
+                    -0.9783513,
+                    0.6837618,
+                    1.5320586,
+                    -1.6148578,
+                    -0.94538075,
+                    0.55978125,
+                    -4.7430468,
+                    3.466028,
+                    2.3792691,
+                    1.3269067,
+                    -0.35359794,
+                    -1.5547276,
+                    0.5202475,
+                    1.0269136,
+                    -1.7531714,
+                    0.43987304,
+                    -0.18845831,
+                    2.3086758,
+                    2.519588,
+                    2.0116413,
+                    0.019745048,
+                    -0.010070452,
+                    2.496933,
+                    1.1557871,
+                    0.08433053,
+                    1.375894,
+                    -1.2135965,
+                    -1.2588277,
+                    -0.31454003,
+                    0.045949124,
+                    -1.7518936,
+                    -2.3533764,
+                    -2.0125146,
+                    0.10255043,
+                    1.1782314,
+                    2.4579153,
+                    -0.8780899,
+                    -4.1442213,
+                    3.8300152,
+                    2.772975,
+                    2.6803262,
+                    0.9867382,
+                    0.77618766,
+                    0.46541777,
+                    3.8959959,
+                    -2.1713195,
+                    0.10609512,
+                    -0.26438138,
+                    -2.145317,
+                    3.6734529,
+                    1.4830295,
+                    -5.3445525,
+                    -10.6427765,
+                    -8.300354,
+                    -1.9608921,
+                    -6.6779685,
+                    -10.019544,
+                    -8.341513,
+                    -9.607174,
+                    -7.2441607,
+                    -3.411102,
+                    -6.180552,
+                    -8.318714,
+                    -6.060591,
+                    -7.790343,
+                    -5.9695,
+                    -7.9429936,
+                    -3.775652,
+                    -5.2827606,
+                    -3.7168224,
+                    -6.729588,
+                    -9.761094,
+                    -7.4683576,
+                    -7.2595067,
+                    -6.6790915,
+                    -9.832726,
+                    -8.352172,
+                    -6.936336,
+                    -8.252518,
+                    -6.787475,
+                    -9.091013,
+                    -11.465944,
+                    -6.712504,
+                    -8.987438,
+                    -6.946672,
+                    -8.877166,
+                    -6.7854185,
+                    -3.6417139,
+                    -6.1036086,
+                    -5.360772,
+                    -4.0435786,
+                    -4.5864973,
+                    -6.971063,
+                    -10.522461,
+                    -6.3692527,
+                    -4.387658,
+                    -9.723745,
+                    -4.7020173,
+                    -5.097396,
+                    -9.903703,
+                    -4.882414,
+                    -4.1999683,
+                    -6.7829437,
+                    -6.2555966,
+                    -8.121125,
+                    -5.334131,
+                    -9.174302,
+                    -3.9752126,
+                    -4.179469,
+                    -8.335524,
+                    -9.359406,
+                    -6.4938803,
+                    -6.794677,
+                    -8.382997,
+                    -9.879416,
+                ],
+                [
+                    1.8792984,
+                    -3.1561708,
+                    -0.8443318,
+                    -1.998743,
+                    -0.6319316,
+                    2.4614046,
+                    -0.44511616,
+                    0.82785237,
+                    1.7911717,
+                    -1.8172283,
+                    0.46574894,
+                    -1.8691323,
+                    3.9586513,
+                    0.8078605,
+                    0.9049874,
+                    5.4086914,
+                    -0.7425967,
+                    -0.20115769,
+                    -1.197923,
+                    2.741789,
+                    0.85432875,
+                    -1.1688408,
+                    -1.7771784,
+                    1.615249,
+                    -4.1103697,
+                    0.4721327,
+                    -2.75669,
+                    -0.38393462,
+                    -3.1137516,
+                    -2.2572582,
+                    0.9580673,
+                    -3.7139492,
+                    -0.68303126,
+                    1.6007807,
+                    0.6313973,
+                    -2.5115106,
+                    0.703251,
+                    2.4844077,
+                    -1.7405633,
+                    -3.007687,
+                    2.372802,
+                    2.4684637,
+                    0.6443977,
+                    -3.1433117,
+                    0.05976736,
+                    -1.9809214,
+                    3.514713,
+                    2.1880944,
+                    1.242541,
+                    1.8236228,
+                    0.8642841,
+                    -0.17313614,
+                    1.7042321,
+                    0.8298376,
+                    4.2443194,
+                    0.13983983,
+                    1.1940852,
+                    2.5076652,
+                    39.285202,
+                    82.73858,
+                    44.707516,
+                    -4.267148,
+                    0.25930226,
+                    0.20799652,
+                    -3.7213502,
+                    1.475217,
+                    -1.2394199,
+                    -0.0034497892,
+                    1.1413965,
+                    55.18923,
+                    -2.2969518,
+                    -4.1400924,
+                    -2.4707043,
+                    43.193188,
+                    -0.19258368,
+                    3.471275,
+                    1.1374166,
+                    1.2147579,
+                    4.13017,
+                    -2.0576499,
+                    2.1529694,
+                    -0.28360432,
+                    0.8477302,
+                    -0.63012695,
+                    1.2569811,
+                    1.943168,
+                    0.17070436,
+                    3.2358394,
+                    -2.3737662,
+                    0.77060974,
+                    4.99065,
+                    3.1079204,
+                    3.6347675,
+                    0.6801177,
+                    -2.2205186,
+                    1.0961101,
+                    -2.4445753,
+                    -2.0919478,
+                    -2.895031,
+                    2.5458927,
+                    0.38599384,
+                    1.0492333,
+                    -0.081834644,
+                    -7.4079595,
+                    -2.1785216,
+                    -0.7277175,
+                    -2.7413428,
+                    -3.2083786,
+                    3.2958643,
+                    -1.1839997,
+                    5.4849496,
+                    2.0259023,
+                    5.607272,
+                    -1.0125756,
+                    3.721461,
+                    2.5715313,
+                    0.7741753,
+                    -0.55034757,
+                    0.7526307,
+                    -2.6758716,
+                    -2.964664,
+                    -0.57379586,
+                    -0.28817406,
+                    -3.2334063,
+                    -0.22387607,
+                    -2.0793931,
+                    -6.4562697,
+                    0.80134094,
+                ],
+            ]
+        )
+    )
+    assert len(events) == 1
diff --git a/tests/ml_models/metrics_correlation/test_mcorr_wavelet_pytest.py b/tests/ml_models/metrics_correlation/test_mcorr_wavelet_pytest.py
new file mode 100644
index 000000000..b2e9e55a2
--- /dev/null
+++ b/tests/ml_models/metrics_correlation/test_mcorr_wavelet_pytest.py
@@ -0,0 +1,201 @@
+# SPDX-License-Identifier: Apache-2.0
+# The OpenSearch Contributors require contributions made to
+# this file be licensed under the Apache-2.0 license or a
+# compatible open source license.
+# Any modifications Copyright OpenSearch Contributors. See
+# GitHub history for details.
+
+"""
+Tests of correctness and compilation for the wavelet module
+of metrics correlation.
+"""
+
+import os
+
+import numpy as np
+import pywt
+import torch
+
+import opensearch_py_ml.ml_models.metrics_correlation.wavelet_piecewise_const as wavpc
+import opensearch_py_ml.ml_models.metrics_correlation.wavelet_tools as wavtools
+
+
+def test_haar_approx():
+    # no truncation -> should return original signal
+    T = 32
+    for _ in range(5):
+        sig = torch.normal(mean=0, std=1, size=[T])
+        torch.testing.assert_close(wavtools.haar_approx(sig, T), sig)
+
+
+def test_haar_truncate():
+    # test truncation against pywavelet library
+    trunc = 10
+    for _ in range(5):
+        sig = torch.normal(mean=0, std=1, size=[32])
+        signp = sig.numpy()
+
+        dec = pywt.wavedec(signp, "haar")
+        arr, slices = pywt.coeffs_to_array(dec)
+        not_top = np.argsort(arr**2)[:-trunc]
+        arr[not_top] = 0
+        rec = pywt.waverec(
+            pywt.array_to_coeffs(arr, slices, output_format="wavedec"), "haar"
+        )
+
+        ptapx = wavtools.haar_approx(sig, truncate=trunc)
+
+        torch.testing.assert_close(ptapx, torch.FloatTensor(rec))
+
+
+def test_haar_padding():
+    # test zero padding
+    T = 17
+    for _ in range(5):
+        sig = torch.normal(mean=0, std=1, size=[T])
+
+        dec = pywt.wavedec(sig, "haar", mode="zero")
+        rec = pywt.waverec(dec, "haar", mode="zero")[:T]
+
+        ptapx = wavtools.haar_approx(sig, truncate=T)
+
+        torch.testing.assert_close(ptapx, torch.FloatTensor(rec))
+
+
+def test_lavielle_criterion():
+    # no curvature (linear cost curve) -> select k=1
+    costs = torch.arange(5) * -1 + 10
+    k, _ = wavpc.lavielle_criterion(costs)
+    assert k == 1
+
+    # elbow at 3 -> select k=3
+    costs = 1 / torch.arange(2, 12) ** 4
+    k, _ = wavpc.lavielle_criterion(costs)
+    assert k == 3
+
+
+def test_blockwise_DP_seg():
+    T = 32
+    max_lvls = 5
+    for _ in range(5):
+        sig = torch.normal(mean=0, std=1, size=[T], dtype=torch.double)
+        segs, costs = wavpc.block_wise_dp_segment(sig, max_lvls=max_lvls)
+
+        for m in range(max_lvls):
+            # at each level i, segs should be len i+1 w/ increasing indices
+            assert len(segs[m + 1]) == m + 2
+            assert np.all(np.diff(segs[m + 1]) > 0)
+
+            # at each level i, costs should not decrease across rows
+            assert np.all(np.diff(costs[m, :]) >= 0)
+
+        # costs should decrease down columns
+        assert np.all(np.diff(costs[:, -1]) <= 0)
+
+
+def test_seg_to_approx():
+    T = 32
+    for _ in range(5):
+        sig = torch.normal(mean=0, std=1, size=[T])
+
+        # generate segments
+        nseg = np.random.choice(np.arange(1, 5))
+        seg = (
+            [0]
+            + list(np.sort(np.random.choice(np.arange(1, T), nseg, replace=False)))
+            + [T]
+        )
+
+        apx = wavpc.block_wise_seg_to_approx(sig, seg)
+
+        # approximation should have # unique vals = # segments
+        assert len(torch.unique(apx)) == len(seg) - 1
+
+        for i in range(len(seg) - 1):
+            seg_start = seg[i]
+            seg_end = seg[i + 1] - 1
+
+            # approximation should be constant inside segments
+            assert len(torch.unique(apx[seg_start : (seg_end + 1)])) == 1
+
+            # segments are left-closed, right open
+            assert apx[seg_start] == apx[seg_end]
+            if i < (len(seg) - 2):
+                assert apx[seg_start] != apx[seg_end + 1]
+
+
+def test_transform_piecewise_constant():
+    T = 32
+
+    # transform of all-zero input is all-zeros
+    sig = torch.zeros(T)
+    for bw in [0.1, 1.0, 10.0]:
+        scores = wavpc.transform_piecewise_const(sig, bw)
+        torch.testing.assert_close(sig[:-1], scores)
+
+    # scores should always be non-negative
+    sig[: T // 2] -= 10.0
+    for _ in range(5):
+        ix = np.random.choice(T)
+        sig[ix:] += torch.normal(0, 1, [1]).item() * 5
+        scores = wavpc.transform_piecewise_const(sig, 1.0)
+        assert torch.all(scores >= 0)
+
+    # blurring should reduce sharp changes
+    # see https://en.wikipedia.org/wiki/Young%27s_convolution_inequality
+    ixs = np.random.choice(T, 3, replace=False)
+    sig[ixs] += 20
+
+    scores = wavpc.transform_piecewise_const(sig, 1.0)
+    assert torch.max(scores) <= torch.max(torch.diff(sig))
+
+
+def test_torchscript_scripting():
+    # test scripting and save / load of scripted model
+    model = WavTest()
+
+    try:
+        jit_processing = torch.jit.script(model)
+    except Exception as exec:
+        assert False, f"Failed to script model with exception {exec}"
+
+    try:
+        torch.jit.save(jit_processing, "wavtest.pt")
+    except Exception as exec:
+        assert False, f"Failed to save model with exception {exec}"
+
+    try:
+        _ = torch.jit.load("wavtest.pt")
+    except Exception as exec:
+        assert False, f"Failed to load scripted model with exception {exec}"
+
+    os.remove("wavtest.pt")
+
+
+def test_torchscript_output():
+    # check scripted model output is same as pytorch
+    sig = torch.normal(mean=0, std=1, size=[1, 32])
+
+    model = WavTest()
+    ptscores = model(sig, 5, 2.0)
+
+    model = WavTest()
+    jit_processing = torch.jit.script(model)
+    torch.jit.save(jit_processing, "wavtest.pt")
+    loaded_model = torch.jit.load("wavtest.pt")
+
+    tsscores = loaded_model.forward(sig, 5, 2.0)
+
+    torch.testing.assert_close(ptscores, tsscores, rtol=0, atol=0)
+
+    os.remove("wavtest.pt")
+
+
+class WavTest(torch.nn.Module):
+    def __init__(self) -> None:
+        super().__init__()
+
+    def forward(self, sig: torch.Tensor, max_dp_levels: int, bandwidth: float):
+        apx = wavpc.wavelet_piecewise_const(sig, 0, max_dp_levels, True)
+        scores = wavpc.transform_piecewise_const(apx, bandwidth)
+        return scores