This is an automated email from the ASF dual-hosted git repository.

Gerrrr pushed a commit to branch params-per-algorithm-flavour
in repository https://gitbox.apache.org/repos/asf/otava-playground.git

commit 00c8f64d30561ff5a775de126458c6bfcdac91d8
Author: Alex Sorokoumov <[email protected]>
AuthorDate: Sun Jun 7 19:27:45 2026 -0700

    Use independent parameters for Otava algorithms
    
    In https://github.com/apache/otava/pull/154, Henrik mentioned that
    using the same P-value for all 3 algorithms is not a fair comparison.
    
    > The deterministic variant finds much less change points for the
    > same p-values. It is closer to original edivisive in behavior than to
    > the hunter variant. To some extent this is a matter of perspective
    too: Who's to say we should compare them with constant p-values
    
    This patch allows setting P-values independently for all 3 algorithms.
    It also "boxes" window size to the split algorithm, which may be a
    more intuitive UX for new users.
---
 src/otava_test_data/tests/test_compare_api.py | 50 +++++++++++++++++
 src/otava_test_data/web/main.py               | 48 ++++++++++++++---
 src/otava_test_data/web/static/css/style.css  | 52 +++++++++++++++++-
 src/otava_test_data/web/static/js/app.js      | 70 ++++++++++++++++--------
 src/otava_test_data/web/templates/index.html  | 77 ++++++++++++++++++---------
 5 files changed, 241 insertions(+), 56 deletions(-)

diff --git a/src/otava_test_data/tests/test_compare_api.py 
b/src/otava_test_data/tests/test_compare_api.py
index f818118..ff84289 100644
--- a/src/otava_test_data/tests/test_compare_api.py
+++ b/src/otava_test_data/tests/test_compare_api.py
@@ -159,3 +159,53 @@ def test_analyze_accepts_otava_algorithm():
     assert "otava" in body
     assert "error" not in body["otava"]
     assert body["otava"]["parameters"]["algorithm"] == "orig"
+
+
+def test_compare_applies_per_algorithm_params():
+    """`algorithm_params` should override the query-string defaults per 
algorithm,
+    and missing per-algorithm keys should fall back to those defaults."""
+    series = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 
5.0]
+    r = client.post(
+        "/api/compare?max_pvalue=0.5&window_len=40",
+        json={
+            "data": series,
+            "algorithms": ["split", "orig"],
+            "algorithm_params": {
+                "split": {"window_len": 20, "max_pvalue": 0.001},
+                "orig":  {"max_pvalue": 0.01},
+            },
+        },
+    )
+    assert r.status_code == 200
+    body = r.json()
+    # The endpoint echoes back the resolved per-algorithm params so the UI can
+    # display "what was actually run."
+    assert body["algorithm_params"]["split"] == {
+        "window_len": 20, "max_pvalue": 0.001, "min_magnitude": 0.0,
+    }
+    # `orig` only overrode max_pvalue; window_len falls back to the query 
default.
+    assert body["algorithm_params"]["orig"] == {
+        "window_len": 40, "max_pvalue": 0.01, "min_magnitude": 0.0,
+    }
+
+
+def test_compare_without_per_algorithm_params_uses_query_defaults():
+    """`algorithm_params` is optional — falling back to query defaults must 
still work."""
+    series = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 
5.0]
+    r = client.post(
+        "/api/compare?max_pvalue=0.05",
+        json={"data": series, "algorithms": ["split"]},
+    )
+    assert r.status_code == 200
+    assert r.json()["algorithm_params"]["split"]["max_pvalue"] == 0.05
+
+
+def test_detect_accepts_otava_algorithm():
+    """`/api/detect` (used by mix mode) should respect the chosen algorithm."""
+    series = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 
5.0]
+    r = client.post(
+        "/api/detect?otava_algorithm=orig&max_pvalue=0.05",
+        json={"data": series},
+    )
+    assert r.status_code == 200
+    assert r.json()["parameters"]["algorithm"] == "orig"
diff --git a/src/otava_test_data/web/main.py b/src/otava_test_data/web/main.py
index 4edbb44..aafc981 100644
--- a/src/otava_test_data/web/main.py
+++ b/src/otava_test_data/web/main.py
@@ -1568,7 +1568,7 @@ async def analyze_with_otava(
     length: int = Query(default=200, ge=10, le=2000),
     seed: int = Query(default=42),
     otava_algorithm: AlgorithmName = Query(default="split", description="Otava 
algorithm to run"),  # noqa: B008
-    window_len: int = Query(default=_OTAVA_DEFAULTS.window_len, ge=5, le=100, 
description="Otava window length"),
+    window_len: int = Query(default=_OTAVA_DEFAULTS.window_len, ge=5, 
le=100000, description="Otava window length"),
     max_pvalue: float = Query(default=_OTAVA_DEFAULTS.max_pvalue, ge=0.0, 
le=1.0, description="Otava max p-value"),
     min_magnitude: float = Query(default=_OTAVA_DEFAULTS.min_magnitude, ge=0, 
description="Minimum change magnitude"),
     tolerance: int = Query(default=5, ge=0, le=50, description="Accuracy 
tolerance"),
@@ -1675,6 +1675,7 @@ class DetectRequest(BaseModel):
 @app.post("/api/detect")
 async def detect_change_points(
     request: DetectRequest,
+    otava_algorithm: AlgorithmName = Query(default="split", description="Otava 
algorithm to run"),  # noqa: B008
     window_len: int = Query(default=_OTAVA_DEFAULTS.window_len, ge=5, 
le=100000, description="Otava window length"),
     max_pvalue: float = Query(default=_OTAVA_DEFAULTS.max_pvalue, ge=0.0, 
le=1.0, description="Otava max p-value"),
     min_magnitude: float = Query(default=_OTAVA_DEFAULTS.min_magnitude, ge=0, 
description="Minimum change magnitude"),
@@ -1693,6 +1694,7 @@ async def detect_change_points(
             window_len=window_len,
             max_pvalue=max_pvalue,
             min_magnitude=min_magnitude,
+            algorithm=otava_algorithm,
         )
         return result
     except Exception as e:
@@ -1749,10 +1751,20 @@ def _run_algorithm(name: str, data, window_len: int, 
max_pvalue: float,
     return out
 
 
+class AlgorithmParams(BaseModel):
+    """Optional per-algorithm parameter overrides for /api/compare."""
+    window_len: int | None = None
+    max_pvalue: float | None = None
+    min_magnitude: float | None = None
+
+
 class CompareRequest(BaseModel):
     """Request body for /api/compare."""
     data: list[float]
     algorithms: list[AlgorithmName] | None = None  # default: all available
+    # Per-algorithm overrides; missing fields fall back to the request's 
query-string
+    # defaults (window_len / max_pvalue / min_magnitude).
+    algorithm_params: dict[AlgorithmName, AlgorithmParams] | None = None
 
 
 @app.get("/api/datasets")
@@ -1783,18 +1795,39 @@ async def compare_algorithms(
     max_pvalue: float = Query(default=_OTAVA_DEFAULTS.max_pvalue, ge=0.0, 
le=1.0),
     min_magnitude: float = Query(default=_OTAVA_DEFAULTS.min_magnitude, ge=0),
 ):
-    """Run multiple change-point algorithms on the same series and return all 
results."""
+    """Run multiple change-point algorithms on the same series and return all 
results.
+
+    Each algorithm can have its own parameter overrides via `algorithm_params`;
+    missing keys fall back to the query-string defaults.
+    """
     if not request.data:
         return JSONResponse(status_code=400, content={"error": "No data 
provided"})
     if not OTAVA_AVAILABLE:
         return JSONResponse(status_code=503, content={"error": "apache-otava 
not installed"})
 
     algorithms = request.algorithms or [n for n, a in ALGORITHMS.items() if 
a["available"]]
-    # run_otava_analysis handles float coercion internally.
-    results = {
-        name: _run_algorithm(name, request.data, window_len, max_pvalue, 
min_magnitude)
-        for name in algorithms
-    }
+    overrides = request.algorithm_params or {}
+
+    def params_for(name: str) -> dict[str, float]:
+        override = overrides.get(name)
+        if override is None:
+            return {"window_len": window_len, "max_pvalue": max_pvalue,
+                    "min_magnitude": min_magnitude}
+        return {
+            "window_len": override.window_len if override.window_len is not 
None else window_len,
+            "max_pvalue": override.max_pvalue if override.max_pvalue is not 
None else max_pvalue,
+            "min_magnitude": (
+                override.min_magnitude if override.min_magnitude is not None 
else min_magnitude
+            ),
+        }
+
+    results = {}
+    per_algo_params = {}
+    for name in algorithms:
+        p = params_for(name)
+        per_algo_params[name] = p
+        results[name] = _run_algorithm(name, request.data, **p)
+
     return {
         "results": results,
         "parameters": {
@@ -1802,6 +1835,7 @@ async def compare_algorithms(
             "max_pvalue": max_pvalue,
             "min_magnitude": min_magnitude,
         },
+        "algorithm_params": per_algo_params,
     }
 
 
diff --git a/src/otava_test_data/web/static/css/style.css 
b/src/otava_test_data/web/static/css/style.css
index dc9c65b..b0a70b1 100644
--- a/src/otava_test_data/web/static/css/style.css
+++ b/src/otava_test_data/web/static/css/style.css
@@ -1097,7 +1097,7 @@ footer a:hover {
 }
 
 /* Wider input for p-value to accommodate small values like 0.00001 */
-#max-pvalue-input {
+.max-pvalue-input {
     width: 90px;
 }
 
@@ -1990,11 +1990,21 @@ body[data-mode="dataset"] .hide-in-dataset {
 .otava-algos {
     display: flex;
     flex-direction: column;
-    gap: 4px;
+    gap: 10px;
     margin: 4px 0 12px;
     font-size: 13px;
 }
 
+.otava-algo {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    padding: 6px 8px;
+    border: 1px solid var(--border-color, #e5e7eb);
+    border-radius: 4px;
+    background: rgba(248, 250, 252, 0.5);
+}
+
 .otava-algo-row {
     display: flex;
     align-items: center;
@@ -2015,3 +2025,41 @@ body[data-mode="dataset"] .hide-in-dataset {
     color: #64748b;
     font-size: 11px;
 }
+
+/* Per-algorithm parameter rows nested inside .otava-algo */
+.otava-algo .algo-params {
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+    margin-left: 24px;
+}
+
+.otava-algo .algo-params .control-group {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    gap: 0.5rem;
+}
+
+.otava-algo .algo-params .control-group label {
+    font-size: 0.75rem;
+    white-space: nowrap;
+    display: inline-block;
+    width: 100px;
+    text-align: left;
+}
+
+.otava-algo .algo-params .control-group input {
+    width: 70px;
+    padding: 0.25rem 0.4rem;
+    font-size: 0.85rem;
+}
+
+.otava-algo .algo-params .max-pvalue-input {
+    width: 90px;
+}
+
+/* Dim parameter inputs when the algorithm checkbox is unchecked. */
+.otava-algo:has(.otava-algo-checkbox:not(:checked)) .algo-params {
+    opacity: 0.45;
+}
diff --git a/src/otava_test_data/web/static/js/app.js 
b/src/otava_test_data/web/static/js/app.js
index f11b691..2108e16 100644
--- a/src/otava_test_data/web/static/js/app.js
+++ b/src/otava_test_data/web/static/js/app.js
@@ -68,8 +68,7 @@ const datasetResultsBody = 
document.getElementById('dataset-results-body');
 
 // DOM Elements - Otava Controls
 const otavaAlgoCheckboxes = document.querySelectorAll('.otava-algo-checkbox');
-const windowLenInput = document.getElementById('window-len-input');
-const maxPvalueInput = document.getElementById('max-pvalue-input');
+const otavaAlgoParamInputs = document.querySelectorAll('.otava-algos 
[data-algo-param]');
 const yMinInput = document.getElementById('y-min-input');
 const yMaxInput = document.getElementById('y-max-input');
 const yMinSlider = document.getElementById('y-min-slider');
@@ -128,6 +127,30 @@ function primaryOtavaAlgorithm() {
     return enabled[0] || 'split';
 }
 
+/** Read the per-algorithm parameter inputs and return a flat dict suitable
+ *  for query-string serialization (e.g. {window_len, max_pvalue}). Window
+ *  length only exists for 'split'; missing inputs are simply absent.
+ *
+ *  Reads the input values regardless of whether the algorithm's checkbox is
+ *  checked — callers (e.g. generateData with the primary algo) may need the
+ *  params even when the algo isn't "enabled" in the multi-select sense. */
+function getOtavaParamsForAlgo(algoName) {
+    const params = {};
+    document
+        .querySelectorAll(`.otava-algo[data-algo="${algoName}"] 
[data-algo-param]`)
+        .forEach(input => { params[input.dataset.algoParam] = input.value; });
+    return params;
+}
+
+/** Per-algorithm params for every enabled algorithm — used by /api/compare. */
+function getOtavaParamsByAlgo() {
+    const out = {};
+    for (const name of getEnabledOtavaAlgorithms()) {
+        out[name] = getOtavaParamsForAlgo(name);
+    }
+    return out;
+}
+
 // DOM Elements - Actions
 const generateBtn = document.getElementById('generate-btn');
 const showAllBtn = document.getElementById('show-all-btn');
@@ -769,8 +792,7 @@ function setupEventListeners() {
 
     // Otava controls
     otavaAlgoCheckboxes.forEach(cb => cb.addEventListener('change', 
refreshDisplay));
-    windowLenInput.addEventListener('change', refreshDisplay);
-    maxPvalueInput.addEventListener('change', refreshDisplay);
+    otavaAlgoParamInputs.forEach(input => input.addEventListener('change', 
refreshDisplay));
 
     // Y-Axis Min slider with bounds
     setupSliderWithBounds(yMinSlider, yMinInput, yMinBoundMin, yMinBoundMax, 
refreshDisplay);
@@ -1233,15 +1255,18 @@ async function generateData() {
     const seed = seedInput.value;
     const runOtava = isOtavaEnabled();
 
-    // Build query params
+    // Single-pattern mode only renders the primary algorithm's detections;
+    // the backend reads only that algorithm's params, so send just those.
+    const algo = primaryOtavaAlgorithm();
+    const algoParams = getOtavaParamsForAlgo(algo);
+
     const params = new URLSearchParams({
         length,
         seed,
         run_otava: runOtava,
-        otava_algorithm: primaryOtavaAlgorithm(),
-        window_len: windowLenInput.value,
-        max_pvalue: maxPvalueInput.value,
+        otava_algorithm: algo,
         tolerance: DEFAULT_TOLERANCE,
+        ...algoParams,
     });
 
     // Add dynamic params
@@ -2055,9 +2080,10 @@ function updateComparisonTables(data) {
 async function showAllPatterns() {
     const length = lengthInput.value;
     const seed = seedInput.value;
-    const windowLen = windowLenInput.value;
-    const maxPvalue = maxPvalueInput.value;
     const tolerance = DEFAULT_TOLERANCE;
+    // /api/analyze runs a single algorithm; "Show All" pins it to the primary.
+    const algo = primaryOtavaAlgorithm();
+    const algoParams = getOtavaParamsForAlgo(algo);
 
     try {
         document.body.classList.add('loading');
@@ -2071,9 +2097,9 @@ async function showAllPatterns() {
             const params = new URLSearchParams({
                 length,
                 seed,
-                window_len: windowLen,
-                max_pvalue: maxPvalue,
                 tolerance,
+                otava_algorithm: algo,
+                ...algoParams,
             });
 
             const response = await fetch(`/api/analyze/${name}?${params}`);
@@ -2512,18 +2538,18 @@ async function runDatasetAnalysis() {
     }
 
     const algos = getEnabledOtavaAlgorithms();
-    const params = new URLSearchParams({
-        window_len: windowLenInput.value,
-        max_pvalue: maxPvalueInput.value,
-        min_magnitude: '0',
-    });
+    const algoParams = getOtavaParamsByAlgo();
 
     try {
         document.body.classList.add('loading');
-        const r = await fetch(`/api/compare?${params}`, {
+        const r = await fetch(`/api/compare`, {
             method: 'POST',
             headers: { 'Content-Type': 'application/json' },
-            body: JSON.stringify({ data: series, algorithms: algos }),
+            body: JSON.stringify({
+                data: series,
+                algorithms: algos,
+                algorithm_params: algoParams,
+            }),
             signal: ctrl.signal,
         });
         if (!r.ok) {
@@ -3076,9 +3102,11 @@ async function computeAndDisplayMixedData() {
     // Run Otava analysis on mixed data if enabled
     if (isOtavaEnabled()) {
         try {
+            // The mix chart renders a single Otava result; pick the primary 
algo.
+            const algo = primaryOtavaAlgorithm();
             const params = new URLSearchParams({
-                window_len: windowLenInput.value,
-                max_pvalue: maxPvalueInput.value,
+                otava_algorithm: algo,
+                ...getOtavaParamsForAlgo(algo),
             });
             const response = await fetch(`/api/detect?${params}`, {
                 method: 'POST',
diff --git a/src/otava_test_data/web/templates/index.html 
b/src/otava_test_data/web/templates/index.html
index 0868d92..a44e724 100644
--- a/src/otava_test_data/web/templates/index.html
+++ b/src/otava_test_data/web/templates/index.html
@@ -148,34 +148,59 @@
                     </div>
 
                     <div class="otava-algos" id="otava-algos">
-                        <label class="otava-algo-row">
-                            <input type="checkbox" class="otava-algo-checkbox"
-                                   data-algo="split" id="otava-algo-split" 
checked>
-                            split-edivisive <span 
class="algo-hint">(default)</span>
-                        </label>
-                        <label class="otava-algo-row">
-                            <input type="checkbox" class="otava-algo-checkbox"
-                                   data-algo="orig" id="otava-algo-orig">
-                            orig-edivisive <span 
class="algo-hint">(permutation test)</span>
-                        </label>
-                        <label class="otava-algo-row">
-                            <input type="checkbox" class="otava-algo-checkbox"
-                                   data-algo="deterministic" 
id="otava-algo-deterministic">
-                            deterministic-edivisive
-                            <span class="algo-hint">(<a 
href="https://github.com/apache/otava/pull/154"; target="_blank">PR 
#154</a>)</span>
-                        </label>
-                    </div>
+                        <div class="otava-algo" data-algo="split">
+                            <label class="otava-algo-row">
+                                <input type="checkbox" 
class="otava-algo-checkbox"
+                                       data-algo="split" id="otava-algo-split" 
checked>
+                                split-edivisive <span 
class="algo-hint">(default)</span>
+                            </label>
+                            <div class="algo-params">
+                                <div class="control-group">
+                                    <label for="window-len-split" 
class="param-label">Window Length</label>
+                                    <input type="number" id="window-len-split" 
data-algo-param="window_len" data-algo="split" value="{{ 
otava_defaults.window_len }}" min="5" max="100000" step="5">
+                                </div>
+                                <div class="control-group">
+                                    <div class="input-with-help">
+                                        <label for="max-pvalue-split" 
class="param-label">Max P-Value</label>
+                                        <input type="number" 
id="max-pvalue-split" class="max-pvalue-input" data-algo-param="max_pvalue" 
data-algo="split" value="{{ otava_defaults.max_pvalue }}" min="0" max="1" 
step="0.001">
+                                        <button type="button" 
class="param-help-btn" data-tooltip="Significance threshold for detecting 
changes. Lower values mean stricter detection (fewer false positives but may 
miss subtle changes). 0.05 is standard; use 0.00001 for very high 
confidence.">?</button>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
 
-                    <div class="panel-fields">
-                        <div class="control-group">
-                            <label for="window-len-input" 
class="param-label">Window Length</label>
-                            <input type="number" id="window-len-input" 
value="{{ otava_defaults.window_len }}" min="5" max="100000" step="5">
+                        <div class="otava-algo" data-algo="orig">
+                            <label class="otava-algo-row">
+                                <input type="checkbox" 
class="otava-algo-checkbox"
+                                       data-algo="orig" id="otava-algo-orig">
+                                orig-edivisive <span 
class="algo-hint">(permutation test)</span>
+                            </label>
+                            <div class="algo-params">
+                                <div class="control-group">
+                                    <div class="input-with-help">
+                                        <label for="max-pvalue-orig" 
class="param-label">Max P-Value</label>
+                                        <input type="number" 
id="max-pvalue-orig" class="max-pvalue-input" data-algo-param="max_pvalue" 
data-algo="orig" value="{{ otava_defaults.max_pvalue }}" min="0" max="1" 
step="0.001">
+                                        <button type="button" 
class="param-help-btn" data-tooltip="Significance threshold for detecting 
changes. Lower values mean stricter detection (fewer false positives but may 
miss subtle changes). 0.05 is standard; use 0.00001 for very high 
confidence.">?</button>
+                                    </div>
+                                </div>
+                            </div>
                         </div>
-                        <div class="control-group">
-                            <div class="input-with-help">
-                                <label for="max-pvalue-input" 
class="param-label">Max P-Value</label>
-                                <input type="number" id="max-pvalue-input" 
value="{{ otava_defaults.max_pvalue }}" step="0.001">
-                                <button type="button" class="param-help-btn" 
data-tooltip="Significance threshold for detecting changes. Lower values mean 
stricter detection (fewer false positives but may miss subtle changes). 0.05 is 
standard; use 0.00001 for very high confidence.">?</button>
+
+                        <div class="otava-algo" data-algo="deterministic">
+                            <label class="otava-algo-row">
+                                <input type="checkbox" 
class="otava-algo-checkbox"
+                                       data-algo="deterministic" 
id="otava-algo-deterministic">
+                                deterministic-edivisive
+                                <span class="algo-hint">(<a 
href="https://github.com/apache/otava/pull/154"; target="_blank">PR 
#154</a>)</span>
+                            </label>
+                            <div class="algo-params">
+                                <div class="control-group">
+                                    <div class="input-with-help">
+                                        <label for="max-pvalue-deterministic" 
class="param-label">Max P-Value</label>
+                                        <input type="number" 
id="max-pvalue-deterministic" class="max-pvalue-input" 
data-algo-param="max_pvalue" data-algo="deterministic" value="{{ 
otava_defaults.max_pvalue }}" min="0" max="1" step="0.001">
+                                        <button type="button" 
class="param-help-btn" data-tooltip="Significance threshold for detecting 
changes. Lower values mean stricter detection (fewer false positives but may 
miss subtle changes). 0.05 is standard; use 0.00001 for very high 
confidence.">?</button>
+                                    </div>
+                                </div>
                             </div>
                         </div>
                     </div>

Reply via email to