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.
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 @@
     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 @@
     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 @@
 @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 @@
             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 @@
     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 @@
     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 @@
             "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 @@
 }
 
 /* Wider input for p-value to accommodate small values like 0.00001 */
-#max-pvalue-input {
+.max-pvalue-input {
     width: 90px;
 }
 
@@ -1990,11 +1990,21 @@
 .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 @@
     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 @@
 
 // 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 @@
     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 @@
 
     // 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 @@
     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 @@
 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 @@
             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 @@
     }
 
     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 @@
     // 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="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="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="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="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="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>